refactor(admin): split email-templates page.tsx into colocated components
Extract tabs nav, templates grid, editor split view, settings form, logs table, and data-loading/actions hook into _components/ and _hooks/. page.tsx reduced from 816 to 88 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CATEGORIES, EmailTemplate, STATUS_BADGE, TemplateVersion } from '../_types'
|
||||||
|
|
||||||
|
interface EditorTabProps {
|
||||||
|
template: EmailTemplate | null
|
||||||
|
version: TemplateVersion | null
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
previewHtml: string | null
|
||||||
|
saving: boolean
|
||||||
|
onSubjectChange: (v: string) => void
|
||||||
|
onHtmlChange: (v: string) => void
|
||||||
|
onSave: () => void
|
||||||
|
onPublish: () => void
|
||||||
|
onPreview: () => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorTab({
|
||||||
|
template, version, subject, html, previewHtml, saving,
|
||||||
|
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
||||||
|
}: EditorTabProps) {
|
||||||
|
if (!template) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
Waehlen Sie ein Template aus der Liste.
|
||||||
|
<br />
|
||||||
|
<button onClick={onBack} className="mt-2 text-purple-600 underline">Zurueck zur Liste</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cat = CATEGORIES[template.category] || CATEGORIES.general
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={onBack} className="text-gray-500 hover:text-gray-700">← Zurueck</button>
|
||||||
|
<h2 className="text-lg font-semibold">{template.name}</h2>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${cat.bgColor} ${cat.color}`}>{cat.label}</span>
|
||||||
|
{version && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${(STATUS_BADGE[version.status] || STATUS_BADGE.draft).color}`}>
|
||||||
|
{(STATUS_BADGE[version.status] || STATUS_BADGE.draft).label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Version speichern'}
|
||||||
|
</button>
|
||||||
|
{version && version.status !== 'published' && (
|
||||||
|
<button
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Publizieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{version && (
|
||||||
|
<button
|
||||||
|
onClick={onPreview}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Vorschau
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variables */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
|
||||||
|
{(template.variables || []).map(v => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => onHtmlChange(html + `{{${v}}}`)}
|
||||||
|
className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs font-mono hover:bg-purple-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Split View */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => onSubjectChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="E-Mail Betreff..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">HTML-Inhalt</label>
|
||||||
|
<textarea
|
||||||
|
value={html}
|
||||||
|
onChange={e => onHtmlChange(e.target.value)}
|
||||||
|
rows={20}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-y"
|
||||||
|
placeholder="<p>Sehr geehrte(r) {{user_name}},</p>"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vorschau</label>
|
||||||
|
<div className="border border-gray-200 rounded-lg bg-white p-4 min-h-[400px]">
|
||||||
|
{previewHtml ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||||
|
) : (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { SendLog } from '../_types'
|
||||||
|
|
||||||
|
interface LogsTabProps {
|
||||||
|
logs: SendLog[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsTab({ logs, total }: LogsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">E-Mail-Verlauf ({total})</h2>
|
||||||
|
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">Noch keine E-Mails gesendet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-2 px-3 text-gray-500 font-medium">Typ</th>
|
||||||
|
<th className="text-left py-2 px-3 text-gray-500 font-medium">Empfaenger</th>
|
||||||
|
<th className="text-left py-2 px-3 text-gray-500 font-medium">Betreff</th>
|
||||||
|
<th className="text-left py-2 px-3 text-gray-500 font-medium">Status</th>
|
||||||
|
<th className="text-left py-2 px-3 text-gray-500 font-medium">Datum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map(log => (
|
||||||
|
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{log.template_type}</td>
|
||||||
|
<td className="py-2 px-3">{log.recipient}</td>
|
||||||
|
<td className="py-2 px-3">{log.subject}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
log.status === 'sent' || log.status === 'test_sent'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-gray-500">
|
||||||
|
{log.sent_at ? new Date(log.sent_at).toLocaleString('de-DE') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Settings } from '../_types'
|
||||||
|
|
||||||
|
interface SettingsTabProps {
|
||||||
|
settings: Settings
|
||||||
|
saving: boolean
|
||||||
|
onChange: (s: Settings) => void
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTab({ settings, saving, onChange, onSave }: SettingsTabProps) {
|
||||||
|
const update = (field: keyof Settings, value: string) => {
|
||||||
|
onChange({ ...settings, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold">E-Mail-Einstellungen</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Absender-Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.sender_name || ''}
|
||||||
|
onChange={e => update('sender_name', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Absender-E-Mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={settings.sender_email || ''}
|
||||||
|
onChange={e => update('sender_email', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Antwort-Adresse</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={settings.reply_to || ''}
|
||||||
|
onChange={e => update('reply_to', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
placeholder="optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.company_name || ''}
|
||||||
|
onChange={e => update('company_name', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Logo URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={settings.logo_url || ''}
|
||||||
|
onChange={e => update('logo_url', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Primaerfarbe</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={settings.primary_color || '#4F46E5'}
|
||||||
|
onChange={e => update('primary_color', e.target.value)}
|
||||||
|
className="h-10 w-10 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.primary_color || ''}
|
||||||
|
onChange={e => update('primary_color', e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundaerfarbe</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={settings.secondary_color || '#7C3AED'}
|
||||||
|
onChange={e => update('secondary_color', e.target.value)}
|
||||||
|
className="h-10 w-10 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.secondary_color || ''}
|
||||||
|
onChange={e => update('secondary_color', e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenadresse</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.company_address || ''}
|
||||||
|
onChange={e => update('company_address', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Footer-Text</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.footer_text || ''}
|
||||||
|
onChange={e => update('footer_text', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { TabId } from '../_types'
|
||||||
|
|
||||||
|
interface TabNavProps {
|
||||||
|
activeTab: TabId
|
||||||
|
onTabChange: (tab: TabId) => void
|
||||||
|
logsTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { id: TabId; label: string }[] = [
|
||||||
|
{ id: 'templates', label: 'Templates' },
|
||||||
|
{ id: 'editor', label: 'Editor' },
|
||||||
|
{ id: 'settings', label: 'Einstellungen' },
|
||||||
|
{ id: 'logs', label: 'Logs' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function TabNav({ activeTab, onTabChange, logsTotal }: TabNavProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex gap-1 -mb-px">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-purple-600 text-purple-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.id === 'logs' && logsTotal > 0 && (
|
||||||
|
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">
|
||||||
|
{logsTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CATEGORIES, EmailTemplate, STATUS_BADGE } from '../_types'
|
||||||
|
|
||||||
|
interface TemplateCardProps {
|
||||||
|
template: EmailTemplate
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateCard({ template, onEdit }: TemplateCardProps) {
|
||||||
|
const cat = CATEGORIES[template.category] || CATEGORIES.general
|
||||||
|
const version = template.latest_version
|
||||||
|
const status = version ? (STATUS_BADGE[version.status] || STATUS_BADGE.draft) : STATUS_BADGE.draft
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm">{template.name}</h3>
|
||||||
|
<span className={`inline-block mt-1 px-2 py-0.5 rounded text-xs ${cat.bgColor} ${cat.color}`}>
|
||||||
|
{cat.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${status.color}`}>{status.label}</span>
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{template.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
|
{(template.variables || []).slice(0, 4).map(v => (
|
||||||
|
<span key={v} className="px-1.5 py-0.5 bg-gray-50 text-gray-500 rounded text-xs font-mono">
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(template.variables || []).length > 4 && (
|
||||||
|
<span className="text-xs text-gray-400">+{template.variables.length - 4}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{version && (
|
||||||
|
<div className="mt-3 text-xs text-gray-400">
|
||||||
|
v{version.version} · {version.created_at ? new Date(version.created_at).toLocaleDateString('de-DE') : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CATEGORIES, EmailTemplate } from '../_types'
|
||||||
|
import { TemplateCard } from './TemplateCard'
|
||||||
|
|
||||||
|
interface TemplatesTabProps {
|
||||||
|
templates: EmailTemplate[]
|
||||||
|
loading: boolean
|
||||||
|
selectedCategory: string | null
|
||||||
|
onCategoryChange: (cat: string | null) => void
|
||||||
|
onEdit: (t: EmailTemplate) => void
|
||||||
|
onInitialize: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesTab({
|
||||||
|
templates, loading, selectedCategory, onCategoryChange, onEdit, onInitialize,
|
||||||
|
}: TemplatesTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Category Pills */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onCategoryChange(null)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
!selectedCategory ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
{Object.entries(CATEGORIES).map(([key, cat]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onCategoryChange(key)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
selectedCategory === key ? 'bg-purple-600 text-white' : `${cat.bgColor} ${cat.color} hover:opacity-80`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Lade Templates...</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 mb-4">Keine Templates vorhanden.</p>
|
||||||
|
<button
|
||||||
|
onClick={onInitialize}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
Standard-Templates erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{templates.map(t => (
|
||||||
|
<TemplateCard key={t.id} template={t} onEdit={() => onEdit(t)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
API_BASE,
|
||||||
|
EmailTemplate,
|
||||||
|
SendLog,
|
||||||
|
Settings,
|
||||||
|
TabId,
|
||||||
|
TemplateType,
|
||||||
|
TemplateVersion,
|
||||||
|
getHeaders,
|
||||||
|
} from '../_types'
|
||||||
|
|
||||||
|
export function useEmailTemplates(activeTab: TabId) {
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([])
|
||||||
|
const [templateTypes, setTemplateTypes] = useState<TemplateType[]>([])
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null)
|
||||||
|
const [logs, setLogs] = useState<SendLog[]>([])
|
||||||
|
const [logsTotal, setLogsTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null)
|
||||||
|
const [editorSubject, setEditorSubject] = useState('')
|
||||||
|
const [editorHtml, setEditorHtml] = useState('')
|
||||||
|
const [editorVersion, setEditorVersion] = useState<TemplateVersion | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Settings form
|
||||||
|
const [settingsForm, setSettingsForm] = useState<Settings | null>(null)
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false)
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const url = selectedCategory ? `${API_BASE}?category=${selectedCategory}` : API_BASE
|
||||||
|
const res = await fetch(url, { headers: getHeaders() })
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setTemplates(Array.isArray(data) ? data : [])
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}, [selectedCategory])
|
||||||
|
|
||||||
|
const loadTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/types`, { headers: getHeaders() })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setTemplateTypes(Array.isArray(data) ? data : [])
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/settings`, { headers: getHeaders() })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSettings(data)
|
||||||
|
setSettingsForm(data)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/logs?limit=50`, { headers: getHeaders() })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLogs(data.logs || [])
|
||||||
|
setLogsTotal(data.total || 0)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([loadTemplates(), loadTypes(), loadSettings()])
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [loadTemplates, loadTypes, loadSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'logs') loadLogs()
|
||||||
|
}, [activeTab, loadLogs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates()
|
||||||
|
}, [selectedCategory, loadTemplates])
|
||||||
|
|
||||||
|
const openEditor = useCallback(async (template: EmailTemplate) => {
|
||||||
|
setSelectedTemplate(template)
|
||||||
|
setPreviewHtml(null)
|
||||||
|
|
||||||
|
if (template.latest_version) {
|
||||||
|
setEditorSubject(template.latest_version.subject)
|
||||||
|
setEditorHtml(template.latest_version.body_html)
|
||||||
|
setEditorVersion(template.latest_version)
|
||||||
|
} else {
|
||||||
|
// Load default content
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/default/${template.template_type}`, { headers: getHeaders() })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setEditorSubject(data.default_subject || '')
|
||||||
|
setEditorHtml(data.default_body_html || '')
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setEditorVersion(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveVersion = useCallback(async () => {
|
||||||
|
if (!selectedTemplate) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/${selectedTemplate.id}/versions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
version: editorVersion ? `${parseFloat(editorVersion.version) + 0.1}` : '1.0',
|
||||||
|
language: 'de',
|
||||||
|
subject: editorSubject,
|
||||||
|
body_html: editorHtml,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const version = await res.json()
|
||||||
|
setEditorVersion(version)
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [selectedTemplate, editorSubject, editorHtml, editorVersion, loadTemplates])
|
||||||
|
|
||||||
|
const publishVersion = useCallback(async () => {
|
||||||
|
if (!editorVersion) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const updated = await res.json()
|
||||||
|
setEditorVersion(updated)
|
||||||
|
await loadTemplates()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [editorVersion, loadTemplates])
|
||||||
|
|
||||||
|
const loadPreview = useCallback(async () => {
|
||||||
|
if (!editorVersion) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ variables: {} }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setPreviewHtml(data.body_html)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [editorVersion])
|
||||||
|
|
||||||
|
const saveSettings2 = useCallback(async () => {
|
||||||
|
if (!settingsForm) return
|
||||||
|
setSavingSettings(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(settingsForm),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSettings(data)
|
||||||
|
setSettingsForm(data)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false)
|
||||||
|
}
|
||||||
|
}, [settingsForm])
|
||||||
|
|
||||||
|
const initializeDefaults = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/initialize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await loadTemplates()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}, [loadTemplates])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Data
|
||||||
|
templates, templateTypes, settings, logs, logsTotal, loading, error,
|
||||||
|
selectedCategory,
|
||||||
|
// Editor
|
||||||
|
selectedTemplate, editorSubject, editorHtml, editorVersion, saving, previewHtml,
|
||||||
|
// Settings
|
||||||
|
settingsForm, savingSettings,
|
||||||
|
// Setters
|
||||||
|
setError, setSelectedCategory,
|
||||||
|
setEditorSubject, setEditorHtml,
|
||||||
|
setSettingsForm,
|
||||||
|
// Actions
|
||||||
|
openEditor, saveVersion, publishVersion, loadPreview,
|
||||||
|
saveSettings2, initializeDefaults,
|
||||||
|
}
|
||||||
|
}
|
||||||
84
admin-compliance/app/sdk/email-templates/_types.ts
Normal file
84
admin-compliance/app/sdk/email-templates/_types.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
export interface EmailTemplate {
|
||||||
|
id: string
|
||||||
|
template_type: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
category: string
|
||||||
|
is_active: boolean
|
||||||
|
sort_order: number
|
||||||
|
variables: string[]
|
||||||
|
latest_version?: TemplateVersion | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateVersion {
|
||||||
|
id: string
|
||||||
|
template_id: string
|
||||||
|
version: string
|
||||||
|
language: string
|
||||||
|
subject: string
|
||||||
|
body_html: string
|
||||||
|
body_text: string | null
|
||||||
|
status: string
|
||||||
|
submitted_at: string | null
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateType {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
variables: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendLog {
|
||||||
|
id: string
|
||||||
|
template_type: string
|
||||||
|
recipient: string
|
||||||
|
subject: string
|
||||||
|
status: string
|
||||||
|
sent_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
sender_name: string
|
||||||
|
sender_email: string
|
||||||
|
reply_to: string | null
|
||||||
|
logo_url: string | null
|
||||||
|
primary_color: string
|
||||||
|
secondary_color: string
|
||||||
|
footer_text: string
|
||||||
|
company_name: string | null
|
||||||
|
company_address: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabId = 'templates' | 'editor' | 'settings' | 'logs'
|
||||||
|
|
||||||
|
export const API_BASE = '/api/sdk/v1/compliance/email-templates'
|
||||||
|
|
||||||
|
export function getHeaders(): HeadersInit {
|
||||||
|
if (typeof window === 'undefined') return { 'Content-Type': 'application/json' }
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||||
|
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORIES: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||||
|
general: { label: 'Allgemein', color: 'text-gray-700', bgColor: 'bg-gray-100' },
|
||||||
|
dsr: { label: 'Betroffenenrechte', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||||
|
consent: { label: 'Einwilligung', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||||
|
breach: { label: 'Datenpanne', color: 'text-red-700', bgColor: 'bg-red-100' },
|
||||||
|
vendor: { label: 'Dienstleister', color: 'text-purple-700', bgColor: 'bg-purple-100' },
|
||||||
|
training: { label: 'Schulung', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
||||||
|
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
|
||||||
|
review: { label: 'Pruefung', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
approved: { label: 'Genehmigt', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
published: { label: 'Publiziert', color: 'bg-green-100 text-green-700' },
|
||||||
|
}
|
||||||
@@ -1,323 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useSDK } from '@/lib/sdk'
|
|
||||||
import { StepHeader } from '@/components/sdk/StepHeader'
|
import { StepHeader } from '@/components/sdk/StepHeader'
|
||||||
|
|
||||||
// =============================================================================
|
import { EmailTemplate, TabId } from './_types'
|
||||||
// TYPES
|
import { useEmailTemplates } from './_hooks/useEmailTemplates'
|
||||||
// =============================================================================
|
import { TabNav } from './_components/TabNav'
|
||||||
|
import { TemplatesTab } from './_components/TemplatesTab'
|
||||||
interface EmailTemplate {
|
import { EditorTab } from './_components/EditorTab'
|
||||||
id: string
|
import { SettingsTab } from './_components/SettingsTab'
|
||||||
template_type: string
|
import { LogsTab } from './_components/LogsTab'
|
||||||
name: string
|
|
||||||
description: string | null
|
|
||||||
category: string
|
|
||||||
is_active: boolean
|
|
||||||
sort_order: number
|
|
||||||
variables: string[]
|
|
||||||
latest_version?: TemplateVersion | null
|
|
||||||
created_at: string | null
|
|
||||||
updated_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplateVersion {
|
|
||||||
id: string
|
|
||||||
template_id: string
|
|
||||||
version: string
|
|
||||||
language: string
|
|
||||||
subject: string
|
|
||||||
body_html: string
|
|
||||||
body_text: string | null
|
|
||||||
status: string
|
|
||||||
submitted_at: string | null
|
|
||||||
published_at: string | null
|
|
||||||
created_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplateType {
|
|
||||||
type: string
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
variables: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SendLog {
|
|
||||||
id: string
|
|
||||||
template_type: string
|
|
||||||
recipient: string
|
|
||||||
subject: string
|
|
||||||
status: string
|
|
||||||
sent_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Settings {
|
|
||||||
sender_name: string
|
|
||||||
sender_email: string
|
|
||||||
reply_to: string | null
|
|
||||||
logo_url: string | null
|
|
||||||
primary_color: string
|
|
||||||
secondary_color: string
|
|
||||||
footer_text: string
|
|
||||||
company_name: string | null
|
|
||||||
company_address: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabId = 'templates' | 'editor' | 'settings' | 'logs'
|
|
||||||
|
|
||||||
const API_BASE = '/api/sdk/v1/compliance/email-templates'
|
|
||||||
|
|
||||||
function getHeaders(): HeadersInit {
|
|
||||||
if (typeof window === 'undefined') return { 'Content-Type': 'application/json' }
|
|
||||||
return {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
|
||||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CATEGORY CONFIG
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const CATEGORIES: Record<string, { label: string; color: string; bgColor: string }> = {
|
|
||||||
general: { label: 'Allgemein', color: 'text-gray-700', bgColor: 'bg-gray-100' },
|
|
||||||
dsr: { label: 'Betroffenenrechte', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
|
||||||
consent: { label: 'Einwilligung', color: 'text-green-700', bgColor: 'bg-green-100' },
|
|
||||||
breach: { label: 'Datenpanne', color: 'text-red-700', bgColor: 'bg-red-100' },
|
|
||||||
vendor: { label: 'Dienstleister', color: 'text-purple-700', bgColor: 'bg-purple-100' },
|
|
||||||
training: { label: 'Schulung', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
|
||||||
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
|
|
||||||
review: { label: 'Pruefung', color: 'bg-yellow-100 text-yellow-700' },
|
|
||||||
approved: { label: 'Genehmigt', color: 'bg-blue-100 text-blue-700' },
|
|
||||||
published: { label: 'Publiziert', color: 'bg-green-100 text-green-700' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function EmailTemplatesPage() {
|
export default function EmailTemplatesPage() {
|
||||||
const sdk = useSDK()
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('templates')
|
const [activeTab, setActiveTab] = useState<TabId>('templates')
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([])
|
const {
|
||||||
const [templateTypes, setTemplateTypes] = useState<TemplateType[]>([])
|
templates, logs, logsTotal, loading, error,
|
||||||
const [settings, setSettings] = useState<Settings | null>(null)
|
selectedCategory,
|
||||||
const [logs, setLogs] = useState<SendLog[]>([])
|
selectedTemplate, editorSubject, editorHtml, editorVersion, saving, previewHtml,
|
||||||
const [logsTotal, setLogsTotal] = useState(0)
|
settingsForm, savingSettings,
|
||||||
const [loading, setLoading] = useState(true)
|
setError, setSelectedCategory,
|
||||||
const [error, setError] = useState<string | null>(null)
|
setEditorSubject, setEditorHtml,
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
setSettingsForm,
|
||||||
|
openEditor, saveVersion, publishVersion, loadPreview,
|
||||||
|
saveSettings2, initializeDefaults,
|
||||||
|
} = useEmailTemplates(activeTab)
|
||||||
|
|
||||||
// Editor state
|
const handleEdit = async (template: EmailTemplate) => {
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null)
|
|
||||||
const [editorSubject, setEditorSubject] = useState('')
|
|
||||||
const [editorHtml, setEditorHtml] = useState('')
|
|
||||||
const [editorVersion, setEditorVersion] = useState<TemplateVersion | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Settings form
|
|
||||||
const [settingsForm, setSettingsForm] = useState<Settings | null>(null)
|
|
||||||
const [savingSettings, setSavingSettings] = useState(false)
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// DATA LOADING
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const loadTemplates = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const url = selectedCategory ? `${API_BASE}?category=${selectedCategory}` : API_BASE
|
|
||||||
const res = await fetch(url, { headers: getHeaders() })
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
setTemplates(Array.isArray(data) ? data : [])
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
}
|
|
||||||
}, [selectedCategory])
|
|
||||||
|
|
||||||
const loadTypes = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/types`, { headers: getHeaders() })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setTemplateTypes(Array.isArray(data) ? data : [])
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/settings`, { headers: getHeaders() })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setSettings(data)
|
|
||||||
setSettingsForm(data)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadLogs = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/logs?limit=50`, { headers: getHeaders() })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setLogs(data.logs || [])
|
|
||||||
setLogsTotal(data.total || 0)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true)
|
|
||||||
Promise.all([loadTemplates(), loadTypes(), loadSettings()])
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [loadTemplates, loadTypes, loadSettings])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'logs') loadLogs()
|
|
||||||
}, [activeTab, loadLogs])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTemplates()
|
|
||||||
}, [selectedCategory, loadTemplates])
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// EDITOR ACTIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const openEditor = useCallback(async (template: EmailTemplate) => {
|
|
||||||
setSelectedTemplate(template)
|
|
||||||
setActiveTab('editor')
|
setActiveTab('editor')
|
||||||
setPreviewHtml(null)
|
await openEditor(template)
|
||||||
|
}
|
||||||
if (template.latest_version) {
|
|
||||||
setEditorSubject(template.latest_version.subject)
|
|
||||||
setEditorHtml(template.latest_version.body_html)
|
|
||||||
setEditorVersion(template.latest_version)
|
|
||||||
} else {
|
|
||||||
// Load default content
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/default/${template.template_type}`, { headers: getHeaders() })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setEditorSubject(data.default_subject || '')
|
|
||||||
setEditorHtml(data.default_body_html || '')
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
setEditorVersion(null)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const saveVersion = useCallback(async () => {
|
|
||||||
if (!selectedTemplate) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/${selectedTemplate.id}/versions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
version: editorVersion ? `${parseFloat(editorVersion.version) + 0.1}` : '1.0',
|
|
||||||
language: 'de',
|
|
||||||
subject: editorSubject,
|
|
||||||
body_html: editorHtml,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const version = await res.json()
|
|
||||||
setEditorVersion(version)
|
|
||||||
await loadTemplates()
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}, [selectedTemplate, editorSubject, editorHtml, editorVersion, loadTemplates])
|
|
||||||
|
|
||||||
const publishVersion = useCallback(async () => {
|
|
||||||
if (!editorVersion) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/publish`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const updated = await res.json()
|
|
||||||
setEditorVersion(updated)
|
|
||||||
await loadTemplates()
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}, [editorVersion, loadTemplates])
|
|
||||||
|
|
||||||
const loadPreview = useCallback(async () => {
|
|
||||||
if (!editorVersion) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/preview`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify({ variables: {} }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setPreviewHtml(data.body_html)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [editorVersion])
|
|
||||||
|
|
||||||
const saveSettings2 = useCallback(async () => {
|
|
||||||
if (!settingsForm) return
|
|
||||||
setSavingSettings(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/settings`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(settingsForm),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setSettings(data)
|
|
||||||
setSettingsForm(data)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setSavingSettings(false)
|
|
||||||
}
|
|
||||||
}, [settingsForm])
|
|
||||||
|
|
||||||
const initializeDefaults = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/initialize`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
await loadTemplates()
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
}
|
|
||||||
}, [loadTemplates])
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// RENDER
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const tabs: { id: TabId; label: string }[] = [
|
|
||||||
{ id: 'templates', label: 'Templates' },
|
|
||||||
{ id: 'editor', label: 'Editor' },
|
|
||||||
{ id: 'settings', label: 'Einstellungen' },
|
|
||||||
{ id: 'logs', label: 'Logs' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -330,38 +41,15 @@ export default function EmailTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
<TabNav activeTab={activeTab} onTabChange={setActiveTab} logsTotal={logsTotal} />
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex gap-1 -mb-px">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-purple-600 text-purple-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{tab.id === 'logs' && logsTotal > 0 && (
|
|
||||||
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">
|
|
||||||
{logsTotal}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === 'templates' && (
|
{activeTab === 'templates' && (
|
||||||
<TemplatesTab
|
<TemplatesTab
|
||||||
templates={templates}
|
templates={templates}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
onCategoryChange={setSelectedCategory}
|
onCategoryChange={setSelectedCategory}
|
||||||
onEdit={openEditor}
|
onEdit={handleEdit}
|
||||||
onInitialize={initializeDefaults}
|
onInitialize={initializeDefaults}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -398,419 +86,3 @@ export default function EmailTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// TAB COMPONENTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function TemplatesTab({
|
|
||||||
templates, loading, selectedCategory, onCategoryChange, onEdit, onInitialize,
|
|
||||||
}: {
|
|
||||||
templates: EmailTemplate[]
|
|
||||||
loading: boolean
|
|
||||||
selectedCategory: string | null
|
|
||||||
onCategoryChange: (cat: string | null) => void
|
|
||||||
onEdit: (t: EmailTemplate) => void
|
|
||||||
onInitialize: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Category Pills */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onCategoryChange(null)}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
|
||||||
!selectedCategory ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
{Object.entries(CATEGORIES).map(([key, cat]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => onCategoryChange(key)}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
|
||||||
selectedCategory === key ? 'bg-purple-600 text-white' : `${cat.bgColor} ${cat.color} hover:opacity-80`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-12 text-gray-500">Lade Templates...</div>
|
|
||||||
) : templates.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-500 mb-4">Keine Templates vorhanden.</p>
|
|
||||||
<button
|
|
||||||
onClick={onInitialize}
|
|
||||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
|
||||||
>
|
|
||||||
Standard-Templates erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{templates.map(t => (
|
|
||||||
<TemplateCard key={t.id} template={t} onEdit={() => onEdit(t)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateCard({ template, onEdit }: { template: EmailTemplate; onEdit: () => void }) {
|
|
||||||
const cat = CATEGORIES[template.category] || CATEGORIES.general
|
|
||||||
const version = template.latest_version
|
|
||||||
const status = version ? (STATUS_BADGE[version.status] || STATUS_BADGE.draft) : STATUS_BADGE.draft
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
|
||||||
onClick={onEdit}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm">{template.name}</h3>
|
|
||||||
<span className={`inline-block mt-1 px-2 py-0.5 rounded text-xs ${cat.bgColor} ${cat.color}`}>
|
|
||||||
{cat.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${status.color}`}>{status.label}</span>
|
|
||||||
</div>
|
|
||||||
{template.description && (
|
|
||||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{template.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1">
|
|
||||||
{(template.variables || []).slice(0, 4).map(v => (
|
|
||||||
<span key={v} className="px-1.5 py-0.5 bg-gray-50 text-gray-500 rounded text-xs font-mono">
|
|
||||||
{`{{${v}}}`}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{(template.variables || []).length > 4 && (
|
|
||||||
<span className="text-xs text-gray-400">+{template.variables.length - 4}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{version && (
|
|
||||||
<div className="mt-3 text-xs text-gray-400">
|
|
||||||
v{version.version} · {version.created_at ? new Date(version.created_at).toLocaleDateString('de-DE') : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorTab({
|
|
||||||
template, version, subject, html, previewHtml, saving,
|
|
||||||
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
|
||||||
}: {
|
|
||||||
template: EmailTemplate | null
|
|
||||||
version: TemplateVersion | null
|
|
||||||
subject: string
|
|
||||||
html: string
|
|
||||||
previewHtml: string | null
|
|
||||||
saving: boolean
|
|
||||||
onSubjectChange: (v: string) => void
|
|
||||||
onHtmlChange: (v: string) => void
|
|
||||||
onSave: () => void
|
|
||||||
onPublish: () => void
|
|
||||||
onPreview: () => void
|
|
||||||
onBack: () => void
|
|
||||||
}) {
|
|
||||||
if (!template) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
Waehlen Sie ein Template aus der Liste.
|
|
||||||
<br />
|
|
||||||
<button onClick={onBack} className="mt-2 text-purple-600 underline">Zurueck zur Liste</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cat = CATEGORIES[template.category] || CATEGORIES.general
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button onClick={onBack} className="text-gray-500 hover:text-gray-700">← Zurueck</button>
|
|
||||||
<h2 className="text-lg font-semibold">{template.name}</h2>
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${cat.bgColor} ${cat.color}`}>{cat.label}</span>
|
|
||||||
{version && (
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${(STATUS_BADGE[version.status] || STATUS_BADGE.draft).color}`}>
|
|
||||||
{(STATUS_BADGE[version.status] || STATUS_BADGE.draft).label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Speichern...' : 'Version speichern'}
|
|
||||||
</button>
|
|
||||||
{version && version.status !== 'published' && (
|
|
||||||
<button
|
|
||||||
onClick={onPublish}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Publizieren
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{version && (
|
|
||||||
<button
|
|
||||||
onClick={onPreview}
|
|
||||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Vorschau
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Variables */}
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
|
|
||||||
{(template.variables || []).map(v => (
|
|
||||||
<button
|
|
||||||
key={v}
|
|
||||||
onClick={() => onHtmlChange(html + `{{${v}}}`)}
|
|
||||||
className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs font-mono hover:bg-purple-100 transition-colors"
|
|
||||||
>
|
|
||||||
{`{{${v}}}`}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Split View */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Editor */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={subject}
|
|
||||||
onChange={e => onSubjectChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
placeholder="E-Mail Betreff..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">HTML-Inhalt</label>
|
|
||||||
<textarea
|
|
||||||
value={html}
|
|
||||||
onChange={e => onHtmlChange(e.target.value)}
|
|
||||||
rows={20}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-y"
|
|
||||||
placeholder="<p>Sehr geehrte(r) {{user_name}},</p>"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vorschau</label>
|
|
||||||
<div className="border border-gray-200 rounded-lg bg-white p-4 min-h-[400px]">
|
|
||||||
{previewHtml ? (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
|
||||||
) : (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsTab({
|
|
||||||
settings, saving, onChange, onSave,
|
|
||||||
}: {
|
|
||||||
settings: Settings
|
|
||||||
saving: boolean
|
|
||||||
onChange: (s: Settings) => void
|
|
||||||
onSave: () => void
|
|
||||||
}) {
|
|
||||||
const update = (field: keyof Settings, value: string) => {
|
|
||||||
onChange({ ...settings, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl space-y-6">
|
|
||||||
<h2 className="text-lg font-semibold">E-Mail-Einstellungen</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Absender-Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.sender_name || ''}
|
|
||||||
onChange={e => update('sender_name', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Absender-E-Mail</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={settings.sender_email || ''}
|
|
||||||
onChange={e => update('sender_email', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Antwort-Adresse</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={settings.reply_to || ''}
|
|
||||||
onChange={e => update('reply_to', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
placeholder="optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.company_name || ''}
|
|
||||||
onChange={e => update('company_name', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Logo URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={settings.logo_url || ''}
|
|
||||||
onChange={e => update('logo_url', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primaerfarbe</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={settings.primary_color || '#4F46E5'}
|
|
||||||
onChange={e => update('primary_color', e.target.value)}
|
|
||||||
className="h-10 w-10 rounded cursor-pointer"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.primary_color || ''}
|
|
||||||
onChange={e => update('primary_color', e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundaerfarbe</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={settings.secondary_color || '#7C3AED'}
|
|
||||||
onChange={e => update('secondary_color', e.target.value)}
|
|
||||||
className="h-10 w-10 rounded cursor-pointer"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.secondary_color || ''}
|
|
||||||
onChange={e => update('secondary_color', e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenadresse</label>
|
|
||||||
<textarea
|
|
||||||
value={settings.company_address || ''}
|
|
||||||
onChange={e => update('company_address', e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Footer-Text</label>
|
|
||||||
<textarea
|
|
||||||
value={settings.footer_text || ''}
|
|
||||||
onChange={e => update('footer_text', e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogsTab({ logs, total }: { logs: SendLog[]; total: number }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold">E-Mail-Verlauf ({total})</h2>
|
|
||||||
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<p className="text-gray-500 text-sm">Noch keine E-Mails gesendet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-200">
|
|
||||||
<th className="text-left py-2 px-3 text-gray-500 font-medium">Typ</th>
|
|
||||||
<th className="text-left py-2 px-3 text-gray-500 font-medium">Empfaenger</th>
|
|
||||||
<th className="text-left py-2 px-3 text-gray-500 font-medium">Betreff</th>
|
|
||||||
<th className="text-left py-2 px-3 text-gray-500 font-medium">Status</th>
|
|
||||||
<th className="text-left py-2 px-3 text-gray-500 font-medium">Datum</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{logs.map(log => (
|
|
||||||
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
||||||
<td className="py-2 px-3 font-mono text-xs">{log.template_type}</td>
|
|
||||||
<td className="py-2 px-3">{log.recipient}</td>
|
|
||||||
<td className="py-2 px-3">{log.subject}</td>
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
log.status === 'sent' || log.status === 'test_sent'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{log.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 text-gray-500">
|
|
||||||
{log.sent_at ? new Date(log.sent_at).toLocaleString('de-DE') : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user