feat: Mail template management UI and API CRUD
- Added full CRUD endpoints for mail templates (create, update, delete, preview) - Introduced Joi validators for template create/update/preview - Updated routes/controller to support ID and type lookups - Built React Templates page with HTML editor, preview, and clipboard helpers - Added navigation entry and route for /templates - Enhanced documentation (README, QUICKSTART, KULLANIM, frontend/backend README)
This commit is contained in:
@@ -8,6 +8,7 @@ import Companies from './pages/Companies';
|
||||
import CompanyDetail from './pages/CompanyDetail';
|
||||
import Tokens from './pages/Tokens';
|
||||
import TokenDetail from './pages/TokenDetail';
|
||||
import Templates from './pages/Templates';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
const theme = createTheme({
|
||||
@@ -56,6 +57,7 @@ function App() {
|
||||
<Route path="companies/:id" element={<CompanyDetail />} />
|
||||
<Route path="tokens" element={<Tokens />} />
|
||||
<Route path="tokens/:id" element={<TokenDetail />} />
|
||||
<Route path="templates" element={<Templates />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Settings,
|
||||
Logout,
|
||||
AccountCircle,
|
||||
Email,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
@@ -34,6 +35,7 @@ const menuItems = [
|
||||
{ text: 'Dashboard', icon: <Dashboard />, path: '/' },
|
||||
{ text: 'Şirketler', icon: <Business />, path: '/companies' },
|
||||
{ text: 'Tokenlar', icon: <TokenIcon />, path: '/tokens' },
|
||||
{ text: 'Mail Şablonları', icon: <Email />, path: '/templates' },
|
||||
{ text: 'Ayarlar', icon: <Settings />, path: '/settings' },
|
||||
];
|
||||
|
||||
|
||||
453
frontend/src/pages/Templates.jsx
Normal file
453
frontend/src/pages/Templates.jsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Edit,
|
||||
Delete,
|
||||
Preview,
|
||||
ContentCopy,
|
||||
} from '@mui/icons-material';
|
||||
import { templateService } from '../services/templateService';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const defaultForm = {
|
||||
name: '',
|
||||
template_type: '',
|
||||
subject_template: '',
|
||||
body_html: '',
|
||||
description: '',
|
||||
active: true,
|
||||
};
|
||||
|
||||
function Templates() {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [form, setForm] = useState(defaultForm);
|
||||
const [activeTab, setActiveTab] = useState('form');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [companyPlaceholder, setCompanyPlaceholder] = useState('Örnek Şirket');
|
||||
const [employeePlaceholder, setEmployeePlaceholder] = useState('Ahmet Yılmaz');
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await templateService.getAll();
|
||||
setTemplates(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load templates:', error);
|
||||
alert('Şablonlar yüklenemedi');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setForm(defaultForm);
|
||||
setActiveTab('form');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = async (template) => {
|
||||
setSelectedTemplate(template);
|
||||
setForm({
|
||||
name: template.name,
|
||||
template_type: template.template_type,
|
||||
subject_template: template.subject_template || '',
|
||||
body_html: template.body_html,
|
||||
description: template.description || '',
|
||||
active: Boolean(template.active),
|
||||
});
|
||||
setActiveTab('form');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl('');
|
||||
}
|
||||
setPreviewHtml('');
|
||||
setActiveTab('form');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (selectedTemplate) {
|
||||
await templateService.update(selectedTemplate.id, form);
|
||||
alert('Şablon güncellendi');
|
||||
} else {
|
||||
await templateService.create(form);
|
||||
alert('Şablon oluşturuldu');
|
||||
}
|
||||
handleCloseDialog();
|
||||
loadTemplates();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Şablon kaydedilemedi';
|
||||
alert(message);
|
||||
console.error('Failed to save template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (template) => {
|
||||
if (!window.confirm(`${template.name} şablonunu silmek istediğinizden emin misiniz?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await templateService.delete(template.id);
|
||||
alert('Şablon silindi');
|
||||
loadTemplates();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Şablon silinemedi';
|
||||
alert(message);
|
||||
console.error('Failed to delete template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async (htmlOverride, options = { openModal: false }) => {
|
||||
try {
|
||||
setPreviewLoading(true);
|
||||
const htmlContent = htmlOverride ?? form.body_html;
|
||||
if (!htmlContent) {
|
||||
alert('Önizleme için HTML içeriği gerekli');
|
||||
return;
|
||||
}
|
||||
const response = await templateService.preview({
|
||||
template_html: htmlContent,
|
||||
company_name: companyPlaceholder,
|
||||
employee_name: employeePlaceholder,
|
||||
});
|
||||
const renderedHtml = response.data.data.rendered_html;
|
||||
setPreviewHtml(renderedHtml);
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
const blobUrl = URL.createObjectURL(new Blob([renderedHtml], { type: 'text/html' }));
|
||||
setPreviewUrl(blobUrl);
|
||||
setActiveTab('preview');
|
||||
if (options.openModal) {
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Önizleme oluşturulamadı';
|
||||
alert(message);
|
||||
console.error('Failed to preview template:', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyTemplateType = (value) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
alert(`Template Type panoya kopyalandı: ${value}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4">Mail Şablonları</Typography>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={handleOpenCreate}>
|
||||
Yeni Şablon
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Şablon Adı</TableCell>
|
||||
<TableCell>Template Type</TableCell>
|
||||
<TableCell>Durum</TableCell>
|
||||
<TableCell>Güncelleme</TableCell>
|
||||
<TableCell align="right">İşlemler</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{templates.map((template) => (
|
||||
<TableRow key={template.id} hover>
|
||||
<TableCell>
|
||||
<Typography fontWeight={600}>{template.name}</Typography>
|
||||
{template.description && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{template.description}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Chip
|
||||
label={template.template_type}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopy fontSize="small" />}
|
||||
onClick={() => copyTemplateType(template.template_type)}
|
||||
>
|
||||
Kopyala
|
||||
</Button>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={template.active ? 'Aktif' : 'Pasif'}
|
||||
color={template.active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(template.updated_at), 'dd/MM/yyyy HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Preview />}
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template);
|
||||
setForm({
|
||||
name: template.name,
|
||||
template_type: template.template_type,
|
||||
subject_template: template.subject_template || '',
|
||||
body_html: template.body_html,
|
||||
description: template.description || '',
|
||||
active: Boolean(template.active),
|
||||
});
|
||||
setCompanyPlaceholder('Örnek Şirket');
|
||||
setEmployeePlaceholder('Ahmet Yılmaz');
|
||||
setActiveTab('preview');
|
||||
handlePreview(template.body_html, { openModal: true });
|
||||
}}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Önizleme
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Edit />}
|
||||
onClick={() => handleOpenEdit(template)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Düzenle
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<Delete />}
|
||||
onClick={() => handleDelete(template)}
|
||||
>
|
||||
Sil
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{selectedTemplate ? 'Şablonu Düzenle' : 'Yeni Şablon Oluştur'}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, value) => setActiveTab(value)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab label="Form" value="form" />
|
||||
<Tab label="Önizleme" value="preview" />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === 'form' && (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<TextField
|
||||
label="Şablon Adı"
|
||||
fullWidth
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Template Type"
|
||||
fullWidth
|
||||
required
|
||||
helperText="Örn: bank, government, internal"
|
||||
value={form.template_type}
|
||||
onChange={(e) => setForm({ ...form, template_type: e.target.value.trim().toLowerCase() })}
|
||||
/>
|
||||
<TextField
|
||||
label="Mail Konusu"
|
||||
fullWidth
|
||||
value={form.subject_template}
|
||||
onChange={(e) => setForm({ ...form, subject_template: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Açıklama"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="HTML İçeriği"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={10}
|
||||
value={form.body_html}
|
||||
onChange={(e) => setForm({ ...form, body_html: e.target.value })}
|
||||
helperText="Handlebars değişkenleri: {{company_name}}, {{employee_name}}, {{tracking_url}}"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Aktif"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField
|
||||
label="Şirket Adı"
|
||||
fullWidth
|
||||
value={companyPlaceholder}
|
||||
onChange={(e) => setCompanyPlaceholder(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Çalışan Adı"
|
||||
fullWidth
|
||||
value={employeePlaceholder}
|
||||
onChange={(e) => setEmployeePlaceholder(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Preview />}
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading || !form.body_html}
|
||||
>
|
||||
{previewLoading ? 'Önizleme Oluşturuluyor...' : 'Önizleme Oluştur'}
|
||||
</Button>
|
||||
<Paper variant="outlined" sx={{ minHeight: 400 }}>
|
||||
{previewHtml ? (
|
||||
<iframe
|
||||
title="template-preview"
|
||||
src={previewUrl}
|
||||
style={{ border: 'none', width: '100%', height: '600px' }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="200px"
|
||||
color="text.secondary"
|
||||
>
|
||||
Önizleme için HTML içeriği girin ve \"Önizleme Oluştur\" butonuna basın
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>İptal</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!form.name || !form.template_type || !form.body_html}>
|
||||
Kaydet
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Önizleme</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<iframe
|
||||
title="template-preview-full"
|
||||
src={previewUrl}
|
||||
style={{ border: 'none', width: '100%', minHeight: '600px' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPreviewOpen(false);
|
||||
}}
|
||||
>
|
||||
Kapat
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Templates;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user