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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user