327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import { Layout } from '@/components/layout/Layout';
|
|
import {
|
|
PrimaryButton,
|
|
DataTable,
|
|
Modal,
|
|
FormField,
|
|
FormSelect,
|
|
FormTextArea,
|
|
Pagination,
|
|
type Column,
|
|
} from '@/components/shared';
|
|
import { Plus, Search, Filter } from 'lucide-react';
|
|
import { notificationService } from '@/services/notification-service';
|
|
import { moduleService } from '@/services/module-service';
|
|
import { showToast } from '@/utils/toast';
|
|
|
|
const NotificationTemplateMaster = (): ReactElement => {
|
|
const [templates, setTemplates] = useState<any[]>([]);
|
|
const [modules, setModules] = useState<any[]>([]);
|
|
const [selectedModule, setSelectedModule] = useState<string>('all');
|
|
const [categories, setCategories] = useState<any[]>([]);
|
|
const [codes, setCodes] = useState<any[]>([]);
|
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
|
|
// Pagination & Search
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [limit, setLimit] = useState(10);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [search, setSearch] = useState('');
|
|
|
|
// 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 fetchData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const [tRes, cRes, mRes] = await Promise.all([
|
|
notificationService.getSuperAdminTemplates({
|
|
limit,
|
|
offset: (currentPage - 1) * limit,
|
|
search,
|
|
module_id: selectedModule === 'all' ? undefined : selectedModule
|
|
}),
|
|
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
|
|
moduleService.getAll(1, 100)
|
|
]);
|
|
|
|
if (tRes.success) {
|
|
setTemplates(tRes.data);
|
|
setTotalItems(tRes.pagination?.total || tRes.data.length);
|
|
setTotalPages(tRes.pagination?.pages || 1);
|
|
}
|
|
if (cRes.success) {
|
|
setCategories(cRes.data);
|
|
}
|
|
if (mRes.success) {
|
|
setModules(mRes.data);
|
|
}
|
|
} catch (err: any) {
|
|
showToast.error(err.message || 'Failed to fetch data');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchData(); }, [currentPage, limit, search, selectedModule]);
|
|
|
|
const fetchCodesForCategory = async (categoryCode: string) => {
|
|
if (!categoryCode) return;
|
|
try {
|
|
const res = await notificationService.getCodesByCategory(categoryCode, { limit: 100 });
|
|
if (res.success) setCodes(res.data);
|
|
} catch (e) {
|
|
console.error('Failed to load codes:', e);
|
|
}
|
|
};
|
|
|
|
const handleCategorySelect = async (categoryCode: string) => {
|
|
setForm(f => ({ ...f, category: categoryCode, code: '' }));
|
|
await fetchCodesForCategory(categoryCode);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
// For now we use createTemplate (which can override based on code)
|
|
await notificationService.createTemplate(form);
|
|
showToast.success(editingId ? 'Template updated' : 'Template created');
|
|
setModalOpen(false);
|
|
fetchData();
|
|
} catch (err: any) {
|
|
showToast.error(err.message || 'Action failed');
|
|
}
|
|
};
|
|
|
|
const columns: Column<any>[] = [
|
|
{ key: 'category', label: 'Category', render: (t) => <span className="text-[10px] font-bold uppercase text-blue-600 bg-blue-50 px-2 py-1 rounded">{t.category_name}</span> },
|
|
{ key: 'code', label: 'Code', render: (t) => <code className="text-xs font-mono font-bold text-gray-700">{t.code}</code> },
|
|
{ key: 'name', label: 'Friendly Name', render: (t) => <span className="text-sm font-medium">{t.name}</span> },
|
|
{ key: 'title', label: 'Preview', render: (t) => <span className="text-xs truncate max-w-[200px] block text-gray-500">{t.title_template}</span> },
|
|
{ key: 'priority', label: 'Priority', render: (t) => <span className="capitalize text-[10px] bg-gray-100 px-1.5 py-0.5 rounded">{t.default_priority}</span> },
|
|
{
|
|
key: 'channels',
|
|
label: 'Channels',
|
|
render: (t) => (
|
|
<div className="flex gap-1">
|
|
{t.channels?.map((c: string) => (
|
|
<span key={c} className="text-[9px] bg-green-50 text-green-700 px-1 rounded border border-green-200 uppercase">{c}</span>
|
|
))}
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
key: 'actions',
|
|
label: 'Actions',
|
|
align: 'right',
|
|
render: (t) => (
|
|
<button onClick={() => {
|
|
setEditingId(t.id);
|
|
setForm({
|
|
code: t.code || '',
|
|
name: t.name || '',
|
|
description: t.description || '',
|
|
category: t.category || '',
|
|
title_template: t.title_template || '',
|
|
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',
|
|
channels: t.channels || ['in_app', 'email'],
|
|
is_active: t.is_active ?? true
|
|
});
|
|
setModalOpen(true);
|
|
}} className="text-xs text-blue-600 hover:underline font-semibold">
|
|
Edit
|
|
</button>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Global Templates"
|
|
pageHeader={{
|
|
title: 'Global Notification Templates',
|
|
description: 'Define default notification templates for all tenants.',
|
|
}}
|
|
>
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden flex flex-col min-h-[600px]">
|
|
<div className="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/50 gap-4">
|
|
<div className="flex items-center gap-4 flex-1">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search templates..."
|
|
className="w-full pl-9 pr-4 py-2 border rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
|
/>
|
|
</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>
|
|
<PrimaryButton onClick={() => {
|
|
setEditingId(null);
|
|
setForm({
|
|
code: '', name: '', description: '', category: '',
|
|
title_template: '', message_template: '',
|
|
email_subject_template: '', email_body_template: '',
|
|
default_priority: 'normal', channels: ['in_app', 'email'],
|
|
is_active: true
|
|
});
|
|
setModalOpen(true);
|
|
}} className="flex gap-2">
|
|
<Plus className="w-4 h-4" /> New Template
|
|
</PrimaryButton>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.id} />
|
|
</div>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
limit={limit}
|
|
onPageChange={setCurrentPage}
|
|
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
|
|
/>
|
|
</div>
|
|
|
|
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingId ? "Edit Notification Template" : "Create New Template"} maxWidth="2xl">
|
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
|
<div className="space-y-4">
|
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Identification</h3>
|
|
<FormSelect
|
|
label="Category"
|
|
value={form.category}
|
|
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}
|
|
placeholder="Select Category"
|
|
/>
|
|
<FormSelect
|
|
label="Event Code"
|
|
value={form.code}
|
|
onValueChange={(val) => {
|
|
const selectedCode = codes.find(c => c.code === val);
|
|
setForm({ ...form, code: val, name: selectedCode?.name || form.name });
|
|
}}
|
|
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}
|
|
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} />
|
|
</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">
|
|
<FormSelect
|
|
label="Priority"
|
|
value={form.default_priority}
|
|
onValueChange={(val) => setForm({...form, default_priority: val})}
|
|
options={[
|
|
{ value: 'low', label: 'Low' },
|
|
{ value: 'normal', label: 'Normal' },
|
|
{ value: 'high', label: 'High' },
|
|
{ value: 'urgent', label: 'Urgent' }
|
|
]}
|
|
/>
|
|
<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});
|
|
}} /> 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});
|
|
}} /> Email
|
|
</label>
|
|
</div>
|
|
</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} />
|
|
</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>
|
|
<FormField
|
|
label="Email Subject"
|
|
value={form.email_subject_template}
|
|
onChange={(e) => setForm({...form, email_subject_template: e.target.value})}
|
|
placeholder="Leave blank to use In-App title"
|
|
/>
|
|
<FormTextArea
|
|
label="Email Body (HTML supported)"
|
|
value={form.email_body_template}
|
|
onChange={(e) => setForm({...form, email_body_template: e.target.value})}
|
|
placeholder="Full HTML body template..."
|
|
rows={6}
|
|
/>
|
|
</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">
|
|
{editingId ? 'Update Template' : 'Create Template'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default NotificationTemplateMaster;
|