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 && (
|
||||
<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">
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
@ -311,6 +296,22 @@ export const SuppliersTable = ({
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<PrimaryButton
|
||||
onClick={handleCreate}
|
||||
|
||||
@ -17,6 +17,27 @@ import { Plus, Code, Search } from 'lucide-react';
|
||||
import { notificationService } from '@/services/notification-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 [categories, setCategories] = useState<any[]>([]);
|
||||
@ -32,7 +53,19 @@ const NotificationMaster = (): ReactElement => {
|
||||
// Category Modal
|
||||
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
||||
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
|
||||
const [codeModalOpen, setCodeModalOpen] = useState<boolean>(false);
|
||||
@ -46,7 +79,17 @@ const NotificationMaster = (): ReactElement => {
|
||||
const [codePages, setCodePages] = useState(0);
|
||||
|
||||
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
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
@ -123,36 +166,45 @@ const NotificationMaster = (): ReactElement => {
|
||||
}
|
||||
}, [codePage]);
|
||||
|
||||
const handleSaveCategory = async () => {
|
||||
const onSaveCategory = async (data: CategoryFormValues) => {
|
||||
try {
|
||||
// Conditionally include module_id only if it's actually selected
|
||||
const payload = { ...data };
|
||||
if (!payload.module_id) {
|
||||
delete payload.module_id;
|
||||
}
|
||||
|
||||
if (editingCategory) {
|
||||
await notificationService.updateCategory(editingCategory.id, categoryForm);
|
||||
await notificationService.updateCategory(editingCategory.id, payload);
|
||||
showToast.success('Category updated');
|
||||
} else {
|
||||
await notificationService.createCategory(categoryForm);
|
||||
await notificationService.createCategory(payload);
|
||||
showToast.success('Category created');
|
||||
}
|
||||
setCategoryModalOpen(false);
|
||||
resetCategory();
|
||||
fetchCategories();
|
||||
} 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 {
|
||||
if (editingCode) {
|
||||
await notificationService.updateCode(editingCode.id, codeForm);
|
||||
await notificationService.updateCode(editingCode.id, data);
|
||||
showToast.success('Code updated');
|
||||
} else {
|
||||
await notificationService.createCode(selectedCategory.id, codeForm);
|
||||
await notificationService.createCode(selectedCategory.id, data);
|
||||
showToast.success('Code created');
|
||||
}
|
||||
setEditingCode(null);
|
||||
setCodeForm({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
||||
resetCode();
|
||||
fetchCodes(selectedCategory, codePage);
|
||||
} 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
|
||||
onEdit={() => {
|
||||
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);
|
||||
}}
|
||||
onDelete={() => {
|
||||
@ -231,7 +283,7 @@ const NotificationMaster = (): ReactElement => {
|
||||
</div>
|
||||
<PrimaryButton onClick={() => {
|
||||
setEditingCategory(null);
|
||||
setCategoryForm({ name: '', code: '', description: '', module_id: '' });
|
||||
resetCategory({ name: '', code: '', description: '', module_id: '' });
|
||||
setCategoryModalOpen(true);
|
||||
}} className="flex gap-2">
|
||||
<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">
|
||||
<div className="space-y-4 p-6">
|
||||
<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="Slug (Code)" value={categoryForm.code} onChange={(e) => setCategoryForm({ ...categoryForm, code: 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)" required placeholder="e.g. workflow" error={categoryErrors.code?.message} {...registerCategory('code')} />
|
||||
</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
|
||||
label="Associated Module"
|
||||
value={categoryForm.module_id}
|
||||
value={watchCategory('module_id')}
|
||||
options={[
|
||||
{ value: '', label: 'System (No Module)' },
|
||||
...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"
|
||||
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={handleSaveCategory}>{editingCategory ? 'Update' : 'Create'}</PrimaryButton>
|
||||
<PrimaryButton onClick={handleCategorySubmit(onSaveCategory)}>{editingCategory ? 'Update' : 'Create'}</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@ -285,15 +338,15 @@ const NotificationMaster = (): ReactElement => {
|
||||
<Plus className="w-3 h-3" /> {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'}
|
||||
</h3>
|
||||
<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="Display Name" value={codeForm.name} onChange={(e) => setCodeForm({ ...codeForm, name: e.target.value })} />
|
||||
<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" 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="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)} 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>
|
||||
{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={handleCodeSubmit(onSaveCode)} size="default"> {editingCode ? 'Update Code' : 'Add Code'}</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -321,7 +374,7 @@ const NotificationMaster = (): ReactElement => {
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => {
|
||||
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>
|
||||
<button onClick={() => {
|
||||
setDeleteTarget({ id: c.id, name: c.code, type: 'code' });
|
||||
|
||||
@ -9,17 +9,37 @@ import {
|
||||
FormSelect,
|
||||
FormTextArea,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Search, Filter } from 'lucide-react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { notificationService } from '@/services/notification-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 [templates, setTemplates] = 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 [codes, setCodes] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
@ -34,20 +54,36 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
// Template Modal
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors }
|
||||
} = useForm<TemplateFormValues>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
title_template: '',
|
||||
message_template: '',
|
||||
email_subject_template: '',
|
||||
email_body_template: '',
|
||||
default_priority: 'normal',
|
||||
channels: ['in_app', 'email'],
|
||||
is_active: true
|
||||
}
|
||||
});
|
||||
|
||||
const channels = watch('channels');
|
||||
const categoryValue = watch('category');
|
||||
const codeValue = watch('code');
|
||||
const priorityValue = watch('default_priority');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@ -56,7 +92,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
limit,
|
||||
offset: (currentPage - 1) * limit,
|
||||
search,
|
||||
module_id: selectedModule === 'all' ? undefined : selectedModule
|
||||
module_id: selectedModule || undefined
|
||||
}),
|
||||
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
|
||||
moduleService.getAll(1, 100)
|
||||
@ -93,19 +129,25 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
};
|
||||
|
||||
const handleCategorySelect = async (categoryCode: string) => {
|
||||
setForm(f => ({ ...f, category: categoryCode, code: '' }));
|
||||
setValue('category', categoryCode, { shouldValidate: true });
|
||||
setValue('code', '', { shouldValidate: true });
|
||||
await fetchCodesForCategory(categoryCode);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const onSave = async (data: TemplateFormValues) => {
|
||||
try {
|
||||
// For now we use createTemplate (which can override based on code)
|
||||
await notificationService.createTemplate(form);
|
||||
if (editingId) {
|
||||
await notificationService.updateTemplate(editingId, data);
|
||||
} else {
|
||||
await notificationService.createTemplate(data);
|
||||
}
|
||||
showToast.success(editingId ? 'Template updated' : 'Template created');
|
||||
setModalOpen(false);
|
||||
reset();
|
||||
fetchData();
|
||||
} 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) => (
|
||||
<button onClick={() => {
|
||||
setEditingId(t.id);
|
||||
setForm({
|
||||
reset({
|
||||
code: t.code || '',
|
||||
name: t.name || '',
|
||||
description: t.description || '',
|
||||
@ -142,7 +184,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
message_template: t.message_template || '',
|
||||
email_subject_template: t.email_subject_template || '',
|
||||
email_body_template: t.email_body_template || '',
|
||||
default_priority: t.default_priority || 'normal',
|
||||
default_priority: (t.default_priority || 'normal') as any,
|
||||
channels: t.channels || ['in_app', 'email'],
|
||||
is_active: t.is_active ?? true
|
||||
});
|
||||
@ -176,27 +218,22 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-[200px]">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-gray-400 whitespace-nowrap">
|
||||
<Filter className="w-3.5 h-3.5" /> Module:
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<FormSelect
|
||||
label=""
|
||||
value={selectedModule}
|
||||
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Modules' },
|
||||
...modules.map(m => ({ value: m.id, label: m.name }))
|
||||
]}
|
||||
placeholder="Filter by Module"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full">
|
||||
<FilterDropdown
|
||||
label="Module"
|
||||
value={selectedModule}
|
||||
onChange={(val) => { setSelectedModule(val as string | null); setCurrentPage(1); }}
|
||||
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
||||
placeholder="All Modules"
|
||||
isSearchable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton onClick={() => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
reset({
|
||||
code: '', name: '', description: '', category: '',
|
||||
title_template: '', message_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>
|
||||
<FormSelect
|
||||
label="Category"
|
||||
value={form.category}
|
||||
required
|
||||
value={categoryValue}
|
||||
onValueChange={handleCategorySelect}
|
||||
options={editingId
|
||||
? [{ value: form.category, label: categories.find(c => c.code === form.category)?.name || form.category }]
|
||||
: categories.map(c => ({ value: c.code, label: c.name }))
|
||||
}
|
||||
disabled={!!editingId}
|
||||
options={categories.map(c => ({ value: c.code, label: c.name }))}
|
||||
error={errors.category?.message}
|
||||
placeholder="Select Category"
|
||||
/>
|
||||
<FormSelect
|
||||
label="Event Code"
|
||||
value={form.code}
|
||||
required
|
||||
value={codeValue}
|
||||
onValueChange={(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
|
||||
? [{ value: form.code, label: `${form.name} (${form.code})` }]
|
||||
: codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))
|
||||
}
|
||||
disabled={!!editingId || !form.category}
|
||||
options={codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))}
|
||||
disabled={!categoryValue}
|
||||
error={errors.code?.message}
|
||||
placeholder="Select Event Code"
|
||||
/>
|
||||
<FormField label="Friendly Name" value={form.name} onChange={(e) => setForm({...form, name: e.target.value})} placeholder="e.g. Project Assigned" />
|
||||
<FormTextArea label="Description" value={form.description} onChange={(e) => setForm({...form, description: e.target.value})} rows={2} />
|
||||
<FormField label="Friendly Name" required error={errors.name?.message} placeholder="e.g. Project Assigned" {...register('name')} />
|
||||
<FormTextArea label="Description" error={errors.description?.message} rows={2} {...register('description')} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
label="Priority"
|
||||
value={form.default_priority}
|
||||
onValueChange={(val) => setForm({...form, default_priority: val})}
|
||||
value={priorityValue}
|
||||
onValueChange={(val) => setValue('default_priority', val as any, { shouldValidate: true })}
|
||||
options={[
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'urgent', label: 'Urgent' }
|
||||
]}
|
||||
error={errors.default_priority?.message}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Channels</label>
|
||||
<div className="flex gap-4 items-center h-10 px-1">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={form.channels.includes('in_app')} onChange={(e) => {
|
||||
const next = e.target.checked ? [...form.channels, 'in_app'] : form.channels.filter(c => c !== 'in_app');
|
||||
setForm({...form, channels: next});
|
||||
<input type="checkbox" checked={channels.includes('in_app')} onChange={(e) => {
|
||||
const next = e.target.checked ? [...channels, 'in_app'] : channels.filter(c => c !== 'in_app');
|
||||
setValue('channels', next, { shouldValidate: true });
|
||||
}} /> In-App
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={form.channels.includes('email')} onChange={(e) => {
|
||||
const next = e.target.checked ? [...form.channels, 'email'] : form.channels.filter(c => c !== 'email');
|
||||
setForm({...form, channels: next});
|
||||
<input type="checkbox" checked={channels.includes('email')} onChange={(e) => {
|
||||
const next = e.target.checked ? [...channels, 'email'] : channels.filter(c => c !== 'email');
|
||||
setValue('channels', next, { shouldValidate: true });
|
||||
}} /> Email
|
||||
</label>
|
||||
</div>
|
||||
{errors.channels && <p className="text-xs text-red-500">{errors.channels.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider pt-2 border-t">In-App Content</h3>
|
||||
<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" value={form.message_template} onChange={(e) => setForm({...form, message_template: e.target.value})} placeholder="Use {{var}} for dynamic data" rows={3} />
|
||||
<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')} />
|
||||
<FormTextArea label="Message Template" required error={errors.message_template?.message} placeholder="Use {{var}} for dynamic data" rows={3} {...register('message_template')} />
|
||||
</div>
|
||||
|
||||
<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
|
||||
label="Email Subject"
|
||||
value={form.email_subject_template}
|
||||
onChange={(e) => setForm({...form, email_subject_template: e.target.value})}
|
||||
error={errors.email_subject_template?.message}
|
||||
placeholder="Leave blank to use In-App title"
|
||||
{...register('email_subject_template')}
|
||||
/>
|
||||
<FormTextArea
|
||||
label="Email Body (HTML supported)"
|
||||
value={form.email_body_template}
|
||||
onChange={(e) => setForm({...form, email_body_template: e.target.value})}
|
||||
error={errors.email_body_template?.message}
|
||||
placeholder="Full HTML body template..."
|
||||
rows={6}
|
||||
{...register('email_body_template')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<PrimaryButton onClick={handleSave} className="px-10">
|
||||
<PrimaryButton onClick={handleSubmit(onSave)} className="px-10">
|
||||
{editingId ? 'Update Template' : 'Create Template'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
@ -16,6 +16,19 @@ import { Edit, RotateCcw, Building, Filter } from 'lucide-react';
|
||||
import { notificationService } from '@/services/notification-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 [templates, setTemplates] = useState<any[]>([]);
|
||||
@ -32,12 +45,21 @@ const NotificationTemplates = (): ReactElement => {
|
||||
// Override Modal
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
||||
const [form, setForm] = useState({
|
||||
title_template: '',
|
||||
message_template: '',
|
||||
email_subject_template: '',
|
||||
email_body_template: '',
|
||||
is_active: true
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors }
|
||||
} = useForm<OverrideFormValues>({
|
||||
resolver: zodResolver(overrideSchema),
|
||||
defaultValues: {
|
||||
title_template: '',
|
||||
message_template: '',
|
||||
email_subject_template: '',
|
||||
email_body_template: '',
|
||||
is_active: true
|
||||
}
|
||||
});
|
||||
|
||||
const fetchModules = async () => {
|
||||
@ -74,14 +96,16 @@ const NotificationTemplates = (): ReactElement => {
|
||||
useEffect(() => { fetchModules(); }, []);
|
||||
useEffect(() => { fetchTemplates(); }, [currentPage, limit, selectedModule]);
|
||||
|
||||
const handleOverride = async () => {
|
||||
const onOverride = async (data: OverrideFormValues) => {
|
||||
try {
|
||||
await notificationService.overrideTemplate(selectedTemplate.code, form);
|
||||
await notificationService.overrideTemplate(selectedTemplate.code, data);
|
||||
showToast.success('Template override saved');
|
||||
setModalOpen(false);
|
||||
reset();
|
||||
fetchTemplates();
|
||||
} 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">
|
||||
<button onClick={() => {
|
||||
setSelectedTemplate(t);
|
||||
setForm({
|
||||
reset({
|
||||
title_template: t.title_template,
|
||||
message_template: t.message_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="space-y-4">
|
||||
<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})} />
|
||||
<FormTextArea label="Message Template" value={form.message_template} onChange={(e) => setForm({...form, message_template: e.target.value})} rows={3} />
|
||||
<FormField label="Title Template" required error={errors.title_template?.message} {...register('title_template')} />
|
||||
<FormTextArea label="Message Template" required error={errors.message_template?.message} rows={3} {...register('message_template')} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
<FormTextArea label="Email Body (HTML)" value={form.email_body_template} onChange={(e) => setForm({...form, email_body_template: e.target.value})} rows={6} />
|
||||
<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)" error={errors.email_body_template?.message} rows={6} {...register('email_body_template')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -203,7 +227,7 @@ const NotificationTemplates = (): ReactElement => {
|
||||
</div>
|
||||
<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>
|
||||
<PrimaryButton onClick={handleOverride} className="px-8">Save Override</PrimaryButton>
|
||||
<PrimaryButton onClick={handleSubmit(onOverride)} className="px-8">Save Override</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -129,6 +129,11 @@ export const notificationService = {
|
||||
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>> => {
|
||||
const response = await apiClient.put(`/notifications/templates/${code}/override`, data);
|
||||
return response.data;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user