diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 854af482..6bd2a05c 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -107,6 +107,7 @@ import SmsDashboardPage from '@/pages/sms/SmsDashboardPage'; import SmsContactsPage from '@/pages/sms/SmsContactsPage'; import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage'; import SmsConversationsPage from '@/pages/sms/SmsConversationsPage'; +import SmsTemplatesPage from '@/pages/sms/SmsTemplatesPage'; import SmsSetupPage from '@/pages/sms/SmsSetupPage'; import PeoplePage from '@/pages/PeoplePage'; import ContactProfilePage from '@/pages/public/ContactProfilePage'; @@ -615,6 +616,14 @@ export default function App() { } /> + + + + } + /> , label: 'SMS Contacts' }, { key: '/app/sms/campaigns', icon: , label: 'SMS Campaigns' }, { key: '/app/sms/conversations', icon: , label: 'SMS Threads' }, + { key: '/app/sms/templates', icon: , label: 'SMS Templates' }, ); } items.push({ diff --git a/admin/src/pages/sms/SmsTemplatesPage.tsx b/admin/src/pages/sms/SmsTemplatesPage.tsx new file mode 100644 index 00000000..540d2f07 --- /dev/null +++ b/admin/src/pages/sms/SmsTemplatesPage.tsx @@ -0,0 +1,406 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Table, Button, Modal, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd'; +import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { api } from '@/lib/api'; +import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms'; +import type { AppOutletContext } from '@/types/api'; +import { useOutletContext } from 'react-router-dom'; +import { useDebounce } from '@/hooks/useDebounce'; + +const { TextArea } = Input; +const { Text } = Typography; + +const CATEGORY_COLORS: Record = { + notification: 'blue', + campaign: 'purple', + custom: 'green', +}; + +/** Known placeholder sample values for live preview */ +const SAMPLE_VALUES: Record = { + name: 'Jane Doe', + phone: '+1 555 000 0000', + shiftTitle: 'Ward 6 Canvass', + shiftTime: '2:00 PM', + shiftDate: 'Mar 15', + shiftLocation: 'Community Centre', + organizationName: 'Changemaker', +}; + +/** Extract {var} names from a template string */ +function extractVars(template: string): string[] { + const vars: string[] = []; + const regex = /\{(\w+)\}/g; + let m; + while ((m = regex.exec(template)) !== null) { + const v = m[1] as string; + if (!vars.includes(v)) vars.push(v); + } + return vars; +} + +/** Calculate SMS segment count */ +function segmentCount(length: number): number { + if (length === 0) return 0; + if (length <= 160) return 1; + return Math.ceil(length / 153); +} + +/** Render a live preview with sample substitutions */ +function renderPreview(template: string): string { + return template.replace(/\{(\w+)\}/g, (match, key) => SAMPLE_VALUES[key] ?? match); +} + +export default function SmsTemplatesPage() { + const { setPageHeader } = useOutletContext(); + const { message } = App.useApp(); + + const [templates, setTemplates] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + + // Filters + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounce(search, 300); + const [categoryFilter, setCategoryFilter] = useState(); + const [favoritesOnly, setFavoritesOnly] = useState(false); + + // Modal + const [modalOpen, setModalOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + // Live template text for character counter + variable extraction + const [liveTemplate, setLiveTemplate] = useState(''); + + useEffect(() => { + setPageHeader({ title: 'SMS Templates', subtitle: 'Manage reusable SMS message templates' }); + }, [setPageHeader]); + + const fetchTemplates = useCallback(async () => { + setLoading(true); + try { + const params: Record = { page, limit: 50 }; + if (debouncedSearch) params.search = debouncedSearch; + if (categoryFilter) params.category = categoryFilter; + if (favoritesOnly) params.isFavorite = 'true'; + const { data } = await api.get>('/sms/templates', { params }); + setTemplates(data.items); + setTotal(data.total); + } finally { + setLoading(false); + } + }, [page, debouncedSearch, categoryFilter, favoritesOnly]); + + useEffect(() => { fetchTemplates(); }, [fetchTemplates]); + + // Reset to page 1 when filters change + useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]); + + const openCreate = () => { + setEditingId(null); + form.resetFields(); + setLiveTemplate(''); + setModalOpen(true); + }; + + const openEdit = (record: SmsMessageTemplate) => { + setEditingId(record.id); + form.setFieldsValue({ + name: record.name, + template: record.template, + description: record.description || '', + category: record.category || undefined, + }); + setLiveTemplate(record.template); + setModalOpen(true); + }; + + const openDuplicate = (record: SmsMessageTemplate) => { + setEditingId(null); + form.setFieldsValue({ + name: `${record.name} (copy)`, + template: record.template, + description: record.description || '', + category: record.category || undefined, + }); + setLiveTemplate(record.template); + setModalOpen(true); + }; + + const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => { + setSaving(true); + try { + if (editingId) { + await api.put(`/sms/templates/${editingId}`, values); + message.success('Template updated'); + } else { + await api.post('/sms/templates', values); + message.success('Template created'); + } + setModalOpen(false); + form.resetFields(); + fetchTemplates(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Save failed'; + message.error(msg); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + try { + await api.delete(`/sms/templates/${id}`); + message.success('Template deleted'); + fetchTemplates(); + } catch { + message.error('Delete failed — system templates cannot be deleted'); + } + }; + + const handleToggleFavorite = async (id: string) => { + try { + await api.post(`/sms/templates/${id}/favorite`); + fetchTemplates(); + } catch { + message.error('Failed to toggle favorite'); + } + }; + + // Computed values for modal + const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]); + const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]); + const charCount = liveTemplate.length; + const segments = segmentCount(charCount); + + const columns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + ellipsis: true, + render: (name, record) => ( + + + handleToggleFavorite(record.id)}> + {record.isFavorite ? : } + + + {name} + {record.isSystem && SYSTEM} + + ), + }, + { + title: 'Category', + dataIndex: 'category', + width: 120, + render: (cat) => cat ? {cat} : -, + }, + { + title: 'Template', + dataIndex: 'template', + ellipsis: true, + width: 300, + render: (tmpl) => ( + + {tmpl.length > 80 ? `${tmpl.slice(0, 80)}...` : tmpl} + + ), + }, + { + title: 'Variables', + width: 200, + render: (_, record) => ( + + {(record.variables || []).map((v) => ( + {`{${v}}`} + ))} + {(!record.variables || record.variables.length === 0) && -} + + ), + }, + { + title: 'Uses', + dataIndex: 'usageCount', + width: 70, + align: 'center', + }, + { + title: 'Updated', + dataIndex: 'updatedAt', + width: 100, + render: (d) => { + const diff = Date.now() - new Date(d).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + }, + }, + { + title: 'Actions', + width: 140, + render: (_, record) => ( + + + + setSearch(e.target.value)} + allowClear + style={{ width: 220 }} + /> + + + + + {charCount} / 1,600 chars + + + {segments} SMS segment{segments !== 1 ? 's' : ''} + + {charCount > 160 && ( + + Multi-part message (153 chars/segment) + + )} + + } + > +