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:
Yashwin 2026-04-14 10:28:09 +05:30
parent 484f3b2e07
commit 2381260190
5 changed files with 249 additions and 130 deletions

View File

@ -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}

View File

@ -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' });

View File

@ -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,7 +54,17 @@ 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({
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors }
} = useForm<TemplateFormValues>({
resolver: zodResolver(templateSchema),
defaultValues: {
code: '', code: '',
name: '', name: '',
description: '', description: '',
@ -46,8 +76,14 @@ const NotificationTemplateMaster = (): ReactElement => {
default_priority: 'normal', default_priority: 'normal',
channels: ['in_app', 'email'], channels: ['in_app', 'email'],
is_active: true 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">
<Filter className="w-3.5 h-3.5" /> Module:
</div>
<div className="w-full"> <div className="w-full">
<FormSelect <FilterDropdown
label="" label="Module"
value={selectedModule} value={selectedModule}
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }} onChange={(val) => { setSelectedModule(val as string | null); setCurrentPage(1); }}
options={[ options={modules.map(m => ({ value: m.id, label: m.name }))}
{ value: 'all', label: 'All Modules' }, placeholder="All Modules"
...modules.map(m => ({ value: m.id, label: m.name })) isSearchable
]}
placeholder="Filter by Module"
/> />
</div> </div>
</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,31 +266,29 @@ 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">
@ -261,59 +296,60 @@ const NotificationTemplateMaster = (): ReactElement => {
<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" value={form.title_template} onChange={(e) => setForm({...form, title_template: e.target.value})} placeholder="e.g. Task Assigned: {{task_name}}" /> <FormField label="Title Template" required error={errors.title_template?.message} placeholder="e.g. Task Assigned: {{task_name}}" {...register('title_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} /> <FormTextArea label="Message Template" required error={errors.message_template?.message} placeholder="Use {{var}} for dynamic data" rows={3} {...register('message_template')} />
</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>

View File

@ -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({
const {
register,
handleSubmit,
reset,
formState: { errors }
} = useForm<OverrideFormValues>({
resolver: zodResolver(overrideSchema),
defaultValues: {
title_template: '', title_template: '',
message_template: '', message_template: '',
email_subject_template: '', email_subject_template: '',
email_body_template: '', email_body_template: '',
is_active: true 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>

View File

@ -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;