Notification updates
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @ai-summary Admin Email Templates mobile screen for managing notification email templates
|
||||
* @ai-context Mobile-optimized version with touch-friendly UI
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, Visibility, Close, Send } from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import { EmailTemplate, UpdateEmailTemplateRequest } from '../types/admin.types';
|
||||
|
||||
const SAMPLE_VARIABLES: Record<string, string> = {
|
||||
userName: 'John Doe',
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
category: 'Routine Maintenance',
|
||||
subtypes: 'Oil Change, Air Filter',
|
||||
dueDate: '2025-01-15',
|
||||
dueMileage: '50,000',
|
||||
documentType: 'Insurance',
|
||||
documentTitle: 'State Farm Policy',
|
||||
expirationDate: '2025-02-28',
|
||||
};
|
||||
|
||||
export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
const { loading: authLoading, isAdmin } = useAdminAccess();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [editIsActive, setEditIsActive] = useState(true);
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const [previewBody, setPreviewBody] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'emailTemplates'],
|
||||
queryFn: () => adminApi.emailTemplates.list(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ key, data }: { key: string; data: UpdateEmailTemplateRequest }) =>
|
||||
adminApi.emailTemplates.update(key, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'emailTemplates'] });
|
||||
toast.success('Template updated');
|
||||
handleCloseEdit();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update template');
|
||||
},
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: ({ key, variables }: { key: string; variables: Record<string, string> }) =>
|
||||
adminApi.emailTemplates.preview(key, variables),
|
||||
onSuccess: (data) => {
|
||||
setPreviewSubject(data.subject);
|
||||
setPreviewBody(data.body);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to generate preview');
|
||||
},
|
||||
});
|
||||
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: (key: string) => adminApi.emailTemplates.sendTest(key),
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
toast.error(`Test email failed: ${data.error}`);
|
||||
} else if (data.message) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to send test email');
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleEditClick = useCallback((template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setEditSubject(template.subject);
|
||||
setEditBody(template.body);
|
||||
setEditIsActive(template.isActive);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClick = useCallback((template: EmailTemplate) => {
|
||||
setPreviewTemplate(template);
|
||||
previewMutation.mutate({
|
||||
key: template.templateKey,
|
||||
variables: SAMPLE_VARIABLES,
|
||||
});
|
||||
}, [previewMutation]);
|
||||
|
||||
const handleSendTestClick = useCallback((template: EmailTemplate) => {
|
||||
sendTestMutation.mutate(template.templateKey);
|
||||
}, [sendTestMutation]);
|
||||
|
||||
const handleCloseEdit = useCallback(() => {
|
||||
setEditingTemplate(null);
|
||||
setEditSubject('');
|
||||
setEditBody('');
|
||||
setEditIsActive(true);
|
||||
}, []);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewTemplate(null);
|
||||
setPreviewSubject('');
|
||||
setPreviewBody('');
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
const data: UpdateEmailTemplateRequest = {
|
||||
subject: editSubject !== editingTemplate.subject ? editSubject : undefined,
|
||||
body: editBody !== editingTemplate.body ? editBody : undefined,
|
||||
isActive: editIsActive !== editingTemplate.isActive ? editIsActive : undefined,
|
||||
};
|
||||
|
||||
// Only update if there are changes
|
||||
if (data.subject || data.body || data.isActive !== undefined) {
|
||||
updateMutation.mutate({
|
||||
key: editingTemplate.templateKey,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
handleCloseEdit();
|
||||
}
|
||||
}, [editingTemplate, editSubject, editBody, editIsActive, updateMutation, handleCloseEdit]);
|
||||
|
||||
// Auth loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
// Edit view
|
||||
if (editingTemplate) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Edit Template</h1>
|
||||
<p className="text-sm text-slate-500">{editingTemplate.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Active Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Active</span>
|
||||
<button
|
||||
onClick={() => setEditIsActive(!editIsActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
|
||||
editIsActive ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editIsActive ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px]"
|
||||
placeholder="Email subject"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
placeholder="Email body"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Variables */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-blue-900 mb-2">Available Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{editingTemplate.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-white border border-blue-300 rounded text-xs font-mono text-blue-700"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:bg-gray-300 min-h-[44px]"
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Preview view
|
||||
if (previewTemplate) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Preview</h1>
|
||||
<p className="text-sm text-slate-500">{previewTemplate.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
This preview uses sample data to show how the template will appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewMutation.isPending ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="text-slate-500 mt-2 text-sm">Generating preview...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg min-h-[44px] flex items-center">
|
||||
{previewSubject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
||||
{previewBody}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Email Templates</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{templates?.length || 0} notification templates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{isLoading ? (
|
||||
<GlassCard>
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="text-slate-500 mt-2 text-sm">Loading templates...</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{templates?.map((template) => (
|
||||
<GlassCard key={template.id}>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-800">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-xs text-slate-500 mt-1">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
template.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Subject</p>
|
||||
<p className="text-sm text-slate-700 truncate">{template.subject}</p>
|
||||
</div>
|
||||
|
||||
{/* Variables */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-gray-100 border border-gray-300 rounded text-xs font-mono text-slate-600"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleEditClick(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePreviewClick(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Visibility fontSize="small" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSendTestClick(template)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors disabled:bg-gray-300 disabled:text-gray-500 min-h-[44px]"
|
||||
>
|
||||
<Send fontSize="small" />
|
||||
{sendTestMutation.isPending ? 'Sending...' : 'Send Test Email'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEmailTemplatesMobileScreen;
|
||||
Reference in New Issue
Block a user