Files
sasa-membership/frontend/src/components/EmailTemplateManagement.tsx
T
nathanb d024bf7fa3 stuff changed:
- ui has been made 'kinda better' (after making it worse for a while lol
- ESP rfid readers are now supported [ill upload the code for them in another repo later]
- admin system has been secured a bit better and seems to be working well
2026-05-08 20:46:58 +01:00

478 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { useToast } from '../contexts/ToastContext';
interface EmailTemplate {
template_key: string;
name: string;
subject: string;
html_body: string;
text_body: string;
variables: string;
is_active: boolean;
}
interface EmailTemplateManagementProps {
searchTerm?: string;
statusFilter?: 'all' | 'active' | 'inactive';
refreshToken?: number;
}
type TemplateSortKey = 'name' | 'key' | 'subject' | 'variables' | 'status';
const parseTemplateVariables = (variables: string): string[] => {
try {
const parsed = JSON.parse(variables);
return Array.isArray(parsed) ? parsed : [];
} catch {
return variables
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
};
const EmailTemplateManagement: React.FC<EmailTemplateManagementProps> = ({
searchTerm = '',
statusFilter = 'all',
refreshToken = 0
}) => {
const toast = useToast();
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
useEffect(() => {
void fetchTemplates();
}, []);
useEffect(() => {
if (refreshToken > 0) {
void fetchTemplates();
}
}, [refreshToken]);
const fetchTemplates = async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
const response = await axios.get('/api/v1/email-templates/', {
headers: { Authorization: `Bearer ${token}` }
});
setTemplates(response.data);
} catch (error) {
console.error('Error fetching email templates:', error);
toast.error('Failed to load email templates.');
} finally {
setLoading(false);
}
};
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
try {
const token = localStorage.getItem('token');
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
headers: { Authorization: `Bearer ${token}` }
});
setEditingTemplate(null);
toast.success('Email template updated.');
void fetchTemplates();
} catch (error) {
console.error('Error updating email template:', error);
toast.error('Failed to update email template.');
}
};
const filteredTemplates = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return templates.filter((template) => {
const matchesSearch = normalizedSearch === '' || [
template.name,
template.template_key,
template.subject
].some((value) => value.toLowerCase().includes(normalizedSearch));
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'active' && template.is_active) ||
(statusFilter === 'inactive' && !template.is_active);
return matchesSearch && matchesStatus;
});
}, [searchTerm, statusFilter, templates]);
const sortedTemplates = useMemo(() => {
const compareValues = (left: string | number, right: string | number) => {
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
};
const sorted = [...filteredTemplates].sort((left, right) => {
let result = 0;
switch (sortKey) {
case 'name':
result = compareValues(left.name, right.name);
break;
case 'key':
result = compareValues(left.template_key, right.template_key);
break;
case 'subject':
result = compareValues(left.subject, right.subject);
break;
case 'variables':
result = compareValues(parseTemplateVariables(left.variables).length, parseTemplateVariables(right.variables).length);
break;
case 'status':
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
break;
}
if (result === 0) {
result = compareValues(left.name, right.name);
}
return sortDirection === 'asc' ? result : -result;
});
return sorted;
}, [filteredTemplates, sortDirection, sortKey]);
const toggleSort = (nextKey: TemplateSortKey) => {
if (sortKey === nextKey) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(nextKey);
setSortDirection('asc');
};
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 10.5 8 6l4 4.5" />
</svg>
</span>
);
if (loading) {
return <p className="admin-empty">Loading email templates...</p>;
}
return (
<div>
<div className="admin-table-shell">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>
<button type="button" className={sortKey === 'name' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('name')}>
<span>Template</span>{renderSortArrow(sortKey === 'name', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'subject' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('subject')}>
<span>Subject</span>{renderSortArrow(sortKey === 'subject', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'variables' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('variables')}>
<span>Variables</span>{renderSortArrow(sortKey === 'variables', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
</button>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sortedTemplates.length === 0 ? (
<tr>
<td className="admin-table-empty" colSpan={6}>No templates match the current filters.</td>
</tr>
) : (
sortedTemplates.map((template) => {
const variables = parseTemplateVariables(template.variables);
return (
<tr key={template.template_key} onClick={() => setEditingTemplate(template)}>
<td>
<strong>{template.name}</strong>
<div className="muted-line">{variables.length} variable{variables.length === 1 ? '' : 's'}</div>
</td>
<td>
<code>{template.template_key}</code>
</td>
<td>{template.subject}</td>
<td>
{variables.length > 0 ? (
<div className="admin-inline-list">
{variables.slice(0, 3).map((variable) => (
<span key={variable} className="admin-inline-chip">{variable}</span>
))}
{variables.length > 3 && <span className="muted-line">+{variables.length - 3} more</span>}
</div>
) : (
<span className="muted-line">None</span>
)}
</td>
<td>
<span className={`status-badge ${template.is_active ? 'status-active' : 'status-expired'}`}>
{template.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div className="table-button-row">
<button
className="btn btn-primary"
type="button"
onClick={(event) => {
event.stopPropagation();
setEditingTemplate(template);
}}
>
Edit
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{editingTemplate && (
<EmailTemplateEditForm
template={editingTemplate}
onSave={handleSaveTemplate}
onCancel={() => setEditingTemplate(null)}
/>
)}
</div>
);
};
interface EmailTemplateEditFormProps {
template: EmailTemplate;
onSave: (template: EmailTemplate) => void;
onCancel: () => void;
}
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
const [previewMode, setPreviewMode] = useState<'rendered' | 'html' | 'text'>('rendered');
const [formData, setFormData] = useState({
name: template.name,
subject: template.subject,
html_body: template.html_body,
text_body: template.text_body,
variables: (() => {
try {
const vars = JSON.parse(template.variables);
return Array.isArray(vars) ? vars : [];
} catch {
return [];
}
})(),
is_active: template.is_active
});
const handleChange = (field: keyof typeof formData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
template_key: template.template_key,
...formData,
variables: JSON.stringify(formData.variables)
});
};
const previewDocument = useMemo(() => {
return `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
padding: 24px;
background: #ffffff;
color: #111111;
font-family: Arial, sans-serif;
}
</style>
</head>
<body>
${formData.html_body}
</body>
</html>
`;
}, [formData.html_body]);
return (
<div className="drawer-overlay" onClick={onCancel}>
<aside className="user-drawer property-drawer" onClick={(event) => event.stopPropagation()}>
<div className="drawer-header">
<div className="drawer-header-main">
<span className="drawer-eyebrow">Template Editor</span>
<h3>Edit Email Template</h3>
<p>{template.name}</p>
</div>
<div className="drawer-header-actions">
<button className="drawer-close" type="button" onClick={onCancel}>×</button>
</div>
</div>
<div className="drawer-hero">
<div className="drawer-hero-grid">
<div className="drawer-hero-card">
<span className="drawer-hero-label">Key</span>
<span className="drawer-hero-value">{template.template_key}</span>
</div>
<div className="drawer-hero-card">
<span className="drawer-hero-label">Status</span>
<span className="drawer-hero-value">
<span className={`status-badge ${formData.is_active ? 'status-active' : 'status-expired'}`}>
{formData.is_active ? 'ACTIVE' : 'INACTIVE'}
</span>
</span>
</div>
</div>
</div>
<div className="drawer-body">
<div className="drawer-section">
<div className="drawer-section-header">
<div>
<h4>Template Content</h4>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Template Key</label>
<input type="text" value={template.template_key} disabled className="admin-field admin-field-disabled" />
</div>
<div className="form-group">
<label>Name</label>
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className="admin-field" required />
</div>
<div className="form-group">
<label>Subject</label>
<input type="text" value={formData.subject} onChange={(e) => handleChange('subject', e.target.value)} className="admin-field" required />
</div>
<div className="form-group">
<label>Variables</label>
<input
type="text"
value={formData.variables.join(', ')}
onChange={(e) => handleChange('variables', e.target.value.split(',').map((v) => v.trim()).filter(Boolean))}
className="admin-field"
/>
</div>
<div className="form-group">
<label>HTML Body</label>
<textarea
value={formData.html_body}
onChange={(e) => handleChange('html_body', e.target.value)}
rows={15}
className="admin-field admin-field-textarea admin-code-textarea"
required
/>
</div>
<div className="form-group">
<label>Text Body</label>
<textarea
value={formData.text_body}
onChange={(e) => handleChange('text_body', e.target.value)}
rows={10}
className="admin-field admin-field-textarea admin-code-textarea"
required
/>
</div>
<label className="admin-switch-row" style={{ marginBottom: '20px' }}>
<input type="checkbox" checked={formData.is_active} onChange={(e) => handleChange('is_active', e.target.checked)} />
Active
</label>
<div className="table-button-row" style={{ justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
<div className="drawer-section">
<div className="drawer-section-header">
<div>
<h4>Preview</h4>
</div>
</div>
<div className="email-preview-tabs" role="tablist" aria-label="Email preview mode">
<button
type="button"
className={previewMode === 'rendered' ? 'email-preview-tab active' : 'email-preview-tab'}
onClick={() => setPreviewMode('rendered')}
>
Rendered
</button>
<button
type="button"
className={previewMode === 'html' ? 'email-preview-tab active' : 'email-preview-tab'}
onClick={() => setPreviewMode('html')}
>
HTML
</button>
<button
type="button"
className={previewMode === 'text' ? 'email-preview-tab active' : 'email-preview-tab'}
onClick={() => setPreviewMode('text')}
>
Text
</button>
</div>
{previewMode === 'rendered' && (
<div className="email-preview-frame-shell">
<iframe
title={`${template.name} preview`}
className="email-preview-frame"
srcDoc={previewDocument}
/>
</div>
)}
{previewMode === 'html' && (
<pre className="email-preview-code">{formData.html_body}</pre>
)}
{previewMode === 'text' && (
<pre className="email-preview-code">{formData.text_body}</pre>
)}
</div>
</div>
</aside>
</div>
);
};
export default EmailTemplateManagement;