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
This commit is contained in:
2026-05-08 20:46:58 +01:00
parent 1a0b4dc25d
commit d024bf7fa3
32 changed files with 7480 additions and 2740 deletions
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { useToast } from '../contexts/ToastContext';
interface EmailTemplate {
template_key: string;
@@ -7,22 +8,55 @@ interface EmailTemplate {
subject: string;
html_body: string;
text_body: string;
variables: string; // This comes as JSON string from backend
variables: string;
is_active: boolean;
}
const EmailTemplateManagement: React.FC = () => {
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 [showEditForm, setShowEditForm] = useState(false);
const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
useEffect(() => {
fetchTemplates();
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}` }
@@ -30,153 +64,202 @@ const EmailTemplateManagement: React.FC = () => {
setTemplates(response.data);
} catch (error) {
console.error('Error fetching email templates:', error);
toast.error('Failed to load email templates.');
} finally {
setLoading(false);
}
};
const handleEditTemplate = (template: EmailTemplate) => {
setEditingTemplate(template);
setShowEditForm(true);
};
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}` }
});
setShowEditForm(false);
setEditingTemplate(null);
fetchTemplates(); // Refresh the list
toast.success('Email template updated.');
void fetchTemplates();
} catch (error) {
console.error('Error updating email template:', error);
toast.error('Failed to update email template.');
}
};
const handleCancelEdit = () => {
setShowEditForm(false);
setEditingTemplate(null);
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 <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>;
return <p className="admin-empty">Loading email templates...</p>;
}
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<button
onClick={fetchTemplates}
style={{
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Refresh Templates
</button>
<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>
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
{templates.map((template) => (
<div
key={template.template_key}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{template.name}</h3>
<div>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
backgroundColor: template.is_active ? '#d4edda' : '#f8d7da',
color: template.is_active ? '#155724' : '#721c24'
}}>
{template.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => handleEditTemplate(template)}
style={{
marginLeft: '10px',
padding: '6px 12px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Edit
</button>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Key:</strong> <code style={{ backgroundColor: '#f8f9fa', padding: '2px 4px', borderRadius: '3px' }}>{template.template_key}</code>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Subject:</strong> <span style={{ color: '#333' }}>{template.subject}</span>
</div>
<div style={{ marginBottom: '15px' }}>
<strong style={{ color: '#666' }}>Variables:</strong>
<div style={{ marginTop: '5px', fontFamily: 'monospace', fontSize: '14px', color: '#333' }}>
{(() => {
try {
const vars = JSON.parse(template.variables);
return Array.isArray(vars) ? vars.join(', ') : template.variables;
} catch {
return template.variables;
}
})()}
</div>
</div>
<div>
<strong style={{ color: '#666' }}>HTML Body Preview:</strong>
<div
style={{
marginTop: '8px',
padding: '12px',
backgroundColor: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '4px',
maxHeight: '200px',
overflow: 'auto',
fontSize: '13px',
lineHeight: '1.4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: '#333'
}}
>
{template.html_body.substring(0, 300)}
{template.html_body.length > 300 ? '...' : ''}
</div>
</div>
</div>
))}
</div>
{showEditForm && editingTemplate && (
{editingTemplate && (
<EmailTemplateEditForm
template={editingTemplate}
onSave={handleSaveTemplate}
onCancel={handleCancelEdit}
onCancel={() => setEditingTemplate(null)}
/>
)}
</div>
@@ -190,6 +273,7 @@ interface EmailTemplateEditFormProps {
}
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,
@@ -206,198 +290,186 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
is_active: template.is_active
});
const handleChange = (field: keyof EmailTemplate, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
const handleChange = (field: keyof typeof formData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const dataToSave = {
onSave({
template_key: template.template_key,
...formData,
variables: JSON.stringify(formData.variables)
};
onSave(dataToSave);
}; return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>Edit Email Template: {template.name}</h3>
});
};
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Template Key:
</label>
<input
type="text"
value={template.template_key}
disabled
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: '#f5f5f5'
}}
/>
<small style={{ color: '#666' }}>Template key cannot be changed</small>
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 style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Name:
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</div>
<div className="drawer-section">
<div className="drawer-section-header">
<div>
<h4>Preview</h4>
</div>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Subject:
</label>
<input
type="text"
value={formData.subject}
onChange={(e) => handleChange('subject', e.target.value)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</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>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Variables (comma-separated):
</label>
<input
type="text"
value={formData.variables.join(', ')}
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
</div>
{previewMode === 'rendered' && (
<div className="email-preview-frame-shell">
<iframe
title={`${template.name} preview`}
className="email-preview-frame"
srcDoc={previewDocument}
/>
</div>
)}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
HTML Body:
</label>
<textarea
value={formData.html_body}
onChange={(e) => handleChange('html_body', e.target.value)}
rows={15}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '14px'
}}
required
/>
</div>
{previewMode === 'html' && (
<pre className="email-preview-code">{formData.html_body}</pre>
)}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Text Body:
</label>
<textarea
value={formData.text_body}
onChange={(e) => handleChange('text_body', e.target.value)}
rows={10}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '14px'
}}
required
/>
{previewMode === 'text' && (
<pre className="email-preview-code">{formData.text_body}</pre>
)}
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
style={{ marginRight: '8px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Save Changes
</button>
</div>
</form>
</div>
</div>
</aside>
</div>
);
};