profit-planet-frontend/src/app/admin/contract-management/components/mailTemplatesManager.tsx
DeathKaioken 4074ea4eee Bibelbumser
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 23:48:09 +02:00

633 lines
27 KiB
TypeScript

'use client';
import { useTranslation } from '../../../i18n/useTranslation';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { authFetch } from '../../../utils/authFetch';
import { useToast } from '../../../components/toast/toastComponent';
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
const MAIL_TEMPLATES_BASE = `${API_BASE}/api/admin/mail-templates`;
/** Convert kebab-case to camelCase for use as i18n key segment */
function toCamelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Derive the i18n translation key for a mail template's subject */
function subjectI18nKey(templateType: string): string {
return `mailTemplates.${toCamelCase(templateType)}.subject`;
}
/** Derive the i18n translation key for a mail template's HTML content */
function htmlI18nKey(templateType: string): string {
return `mailTemplates.${toCamelCase(templateType)}.htmlContent`;
}
/**
* Sync mail template content to the i18n translation files so it appears
* in Language Management and can be translated to other languages.
*/
async function syncToI18n(templateType: string, subject: string, htmlContent: string): Promise<void> {
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
const data = await res.json();
if (!res.ok || !data?.ok) throw new Error(data?.message || 'Failed to load translations');
const translations: Record<string, Record<string, string>> = data.translations ?? {};
const sKey = subjectI18nKey(templateType);
const hKey = htmlI18nKey(templateType);
const updated = {
...translations,
en: { ...(translations.en ?? {}), [sKey]: subject, [hKey]: htmlContent },
};
const putRes = await fetch('/api/i18n/translations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ translations: updated }),
});
const putData = await putRes.json();
if (!putRes.ok || !putData?.ok) throw new Error(putData?.message || 'Failed to sync to Language Management');
}
type MailTemplate = {
id: number;
template_type: string;
name: string;
subject: string | null;
html_content: string;
is_active: boolean;
is_archived: boolean;
archived_at: string | null;
created_at: string;
updated_at: string;
};
type EditorState = {
template_type: string;
name: string;
subject: string;
html_content: string;
};
function createBlankEditor(t: (key: string) => string): EditorState {
return {
template_type: '',
name: '',
subject: '',
html_content: `<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>${t('autofix.k88f0d12a')}</h2><p>${t('autofix.k4f530782')}</p></div>`,
};
}
export default function MailTemplatesManager() {
const { t } = useTranslation();
const { showToast } = useToast();
const blankEditor = useMemo(() => createBlankEditor(t), [t]);
const [tab, setTab] = useState<'active' | 'archived'>('active');
const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle');
const [editor, setEditor] = useState<EditorState>(blankEditor);
const [isSaving, setIsSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const fetchTemplates = useCallback(async (includeArchived: boolean) => {
setIsLoading(true);
setFetchError(null);
try {
const url = `${MAIL_TEMPLATES_BASE}${includeArchived ? '?includeArchived=true' : ''}`;
const res = await authFetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
const raw = await res.json();
// API may return a bare array or a wrapper object { data: [...] } / { templates: [...] }
const data: MailTemplate[] = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: Array.isArray(raw?.templates)
? raw.templates
: [];
setTemplates(data);
} catch (e: any) {
setFetchError(e?.message || 'Failed to load templates');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates(tab === 'archived');
setSelectedId(null);
setIsCreating(false);
setEditor(blankEditor);
}, [tab, fetchTemplates, blankEditor]);
const visibleTemplates = tab === 'archived'
? templates.filter((tmpl) => tmpl.is_archived)
: templates.filter((tmpl) => !tmpl.is_archived);
const selectedTemplate = templates.find((tmpl) => tmpl.id === selectedId) ?? null;
const openEditor = (template: MailTemplate) => {
setIsCreating(false);
setSelectedId(template.id);
setI18nSyncStatus('idle');
setEditor({
template_type: template.template_type,
name: template.name,
subject: template.subject ?? '',
html_content: template.html_content,
});
};
const startCreate = () => {
setIsCreating(true);
setSelectedId(null);
setI18nSyncStatus('idle');
setEditor(blankEditor);
};
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
setI18nSyncStatus('idle');
let savedTemplateType = editor.template_type;
let savedSubject = editor.subject;
let savedHtmlContent = editor.html_content;
try {
if (isCreating) {
const res = await authFetch(MAIL_TEMPLATES_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_type: editor.template_type,
name: editor.name,
subject: editor.subject || undefined,
html_content: editor.html_content,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
const created: MailTemplate = await res.json();
savedTemplateType = created.template_type;
savedSubject = created.subject ?? editor.subject;
savedHtmlContent = created.html_content;
showToast({ variant: 'success', message: t('autofix.ke8a3bd92') });
await fetchTemplates(false);
setTab('active');
setIsCreating(false);
setSelectedId(created.id);
setEditor({
template_type: created.template_type,
name: created.name,
subject: created.subject ?? '',
html_content: created.html_content,
});
} else if (selectedId !== null) {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${selectedId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_type: editor.template_type,
name: editor.name,
subject: editor.subject || undefined,
html_content: editor.html_content,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ variant: 'success', message: t('autofix.k9535ed27') });
await fetchTemplates(tab === 'archived');
}
// Sync content to i18n system so it's translatable in Language Management
if (savedTemplateType?.trim()) {
setI18nSyncStatus('syncing');
syncToI18n(savedTemplateType, savedSubject, savedHtmlContent)
.then(() => setI18nSyncStatus('synced'))
.catch(() => setI18nSyncStatus('error'));
}
} catch (e: any) {
showToast({ variant: 'error', message: e?.message || t('autofix.kb743b7c2') });
} finally {
setIsSaving(false);
}
};
const handleActivate = async (id: number) => {
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/activate`, { method: 'PATCH' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ variant: 'success', message: t('autofix.ke1a18ce6') });
await fetchTemplates(tab === 'archived');
} catch (e: any) {
showToast({ variant: 'error', message: e?.message || t('autofix.k1c5f641a') });
} finally {
setActionLoadingId(null);
}
};
const handleArchive = async (id: number) => {
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/archive`, { method: 'PATCH' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ variant: 'success', message: t('autofix.k2f162f5d') });
if (selectedId === id) {
setSelectedId(null);
setEditor(blankEditor);
}
await fetchTemplates(tab === 'archived');
} catch (e: any) {
showToast({ variant: 'error', message: e?.message || t('autofix.kff7b3b21') });
} finally {
setActionLoadingId(null);
}
};
const handleUnarchive = async (id: number) => {
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/unarchive`, { method: 'PATCH' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || (err as any)?.error || `Error ${res.status}`);
}
showToast({ variant: 'success', message: t('autofix.kc3a71e92') });
setSelectedId(null);
setEditor(blankEditor);
setTab('active');
await fetchTemplates(false);
} catch (e: any) {
showToast({ variant: 'error', message: e?.message || t('autofix.ka91f3c05') });
} finally {
setActionLoadingId(null);
}
};
const removeI18nKeys = async (templateType: string) => {
try {
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
const data = await res.json();
if (!res.ok || !data?.ok) return;
const translations: Record<string, Record<string, string>> = data.translations ?? {};
const sKey = subjectI18nKey(templateType);
const hKey = htmlI18nKey(templateType);
const updated: Record<string, Record<string, string>> = {};
for (const lang of Object.keys(translations)) {
const copy = { ...translations[lang] };
delete copy[sKey];
delete copy[hKey];
updated[lang] = copy;
}
await fetch('/api/i18n/translations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ translations: updated }),
});
} catch {
// best-effort — don't block the delete flow
}
};
const handleDelete = async (id: number) => {
if (!window.confirm(t('autofix.ka63bb731'))) return;
const templateType = templates.find((tmpl) => tmpl.id === id)?.template_type ?? '';
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ variant: 'success', message: t('autofix.kf6b83106') });
if (selectedId === id) {
setSelectedId(null);
setEditor(blankEditor);
}
await fetchTemplates(tab === 'archived');
if (templateType.trim()) {
await removeI18nKeys(templateType);
}
} catch (e: any) {
showToast({ variant: 'error', message: e?.message || t('autofix.kccf6593a') });
} finally {
setActionLoadingId(null);
}
};
const editorOpen = isCreating || selectedId !== null;
return (
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
{/* Header */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-xl font-semibold text-slate-900">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{t('autofix.kd93a60af')}
</h2>
<div className="flex flex-wrap gap-2">
{tab === 'active' && (
<button
type="button"
onClick={startCreate}
className="inline-flex items-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
>
+ New Mail Template
</button>
)}
{editorOpen && (
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50 transition disabled:opacity-50"
>{isSaving ? t('autofix.kac6cedc7') : (isCreating ? t('autofix.k987f2b90') : t('autofix.k9f7c3d1e'))}</button>
)}
<button
type="button"
onClick={() => fetchTemplates(tab === 'archived')}
disabled={isLoading}
title="Refresh"
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 transition disabled:opacity-50"
>
<svg className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Tabs */}
<div className="mb-5 flex gap-1 rounded-2xl border border-slate-200 bg-slate-100/60 p-1 w-fit">
<button
type="button"
onClick={() => setTab('active')}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
tab === 'active' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>{t('autofix.kbdcb654a')}</button>
<button
type="button"
onClick={() => setTab('archived')}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
tab === 'archived' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>
Archived
</button>
</div>
{fetchError && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{fetchError}
</div>
)}
<div className={`grid gap-4 ${editorOpen ? 'lg:grid-cols-[290px_minmax(0,1fr)]' : ''}`}>
{/* Template List */}
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3">
<h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">{tab === 'archived' ? t('autofix.kc097ece0') : t('autofix.k2fbe0857')}</h3>
{isLoading && (
<div className="px-2 py-4 text-sm text-slate-400 text-center">{t('autofix.k832387c5')}</div>
)}
{!isLoading && visibleTemplates.length === 0 && !isCreating && (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">{tab === 'archived' ? t('autofix.k245ba4af') : t('autofix.k247b74e1')}</div>
)}
{isCreating && (
<div className="mb-2 rounded-xl border border-slate-900 bg-slate-900 px-3 py-2 text-white">
<div className="text-sm font-semibold truncate">{t('autofix.k2f343849')}</div>
<div className="mt-0.5 text-[11px] text-slate-300">Draft</div>
</div>
)}
<div className="space-y-2">
{visibleTemplates.map((template) => {
const isSelected = !isCreating && selectedId === template.id;
const isBusy = actionLoadingId === template.id;
return (
<div
key={template.id}
className={`rounded-xl border transition ${
isSelected ? 'border-slate-900 bg-slate-900' : 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<button
type="button"
onClick={() => openEditor(template)}
className="w-full px-3 pt-2 pb-1 text-left"
>
<div className={`text-sm font-semibold truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{template.name}
</div>
<div className={`mt-0.5 text-[11px] flex items-center gap-1.5 ${isSelected ? 'text-slate-300' : 'text-slate-500'}`}>
<span className="truncate">{template.template_type}</span>
{template.is_active && !template.is_archived && (
<span className={`shrink-0 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-emerald-500 text-white' : 'bg-emerald-100 text-emerald-700'}`}>
Active
</span>
)}
</div>
</button>
<div className="flex flex-wrap items-center gap-1 px-3 pb-2">
<button
type="button"
title="Edit"
onClick={() => openEditor(template)}
className={`rounded-lg px-2 py-1 text-[11px] font-semibold transition ${
isSelected ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Edit
</button>
{!template.is_archived && !template.is_active && (
<button
type="button"
title="Activate"
disabled={isBusy}
onClick={() => handleActivate(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 transition disabled:opacity-50"
>
Activate
</button>
)}
{!template.is_archived && template.is_active && (
<span className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-100 text-emerald-700 cursor-default select-none">{t('autofix.k26404a1a')}</span>
)}
{!template.is_archived && (
<button
type="button"
title="Archive"
disabled={isBusy}
onClick={() => handleArchive(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-amber-50 text-amber-700 hover:bg-amber-100 transition disabled:opacity-50"
>
Archive
</button>
)}
{template.is_archived && (
<button
type="button"
title="Unarchive"
disabled={isBusy}
onClick={() => handleUnarchive(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-sky-50 text-sky-700 hover:bg-sky-100 transition disabled:opacity-50"
>
Unarchive
</button>
)}
<button
type="button"
title="Delete"
disabled={isBusy}
onClick={() => handleDelete(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-rose-50 text-rose-700 hover:bg-rose-100 transition disabled:opacity-50"
>
Delete
</button>
{isBusy && (
<svg className="ml-1 w-3.5 h-3.5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
)}
</div>
</div>
);
})}
</div>
</aside>
{/* Editor + Preview */}
{editorOpen && (
<div className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="block">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fb166ad')}</div>
<input
value={editor.template_type}
onChange={(e) => { setEditor((s) => ({ ...s, template_type: e.target.value })); setI18nSyncStatus('idle'); }}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder={t('autofix.k1a7aa84d')}
/>
</label>
<label className="block">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fc164d2')}</div>
<input
value={editor.name}
onChange={(e) => setEditor((s) => ({ ...s, name: e.target.value }))}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder={t('autofix.k32764a91')}
/>
</label>
{/* i18n key info + sync status */}
{editor.template_type?.trim() && (
<div className="md:col-span-2 rounded-xl border border-sky-200 bg-sky-50/60 px-4 py-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-sky-700">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>{t('autofix.k48b366e4')}</div>
{i18nSyncStatus === 'syncing' && (
<span className="text-[11px] text-sky-500 flex items-center gap-1">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>{t('autofix.ke4326584')}</span>
)}
{i18nSyncStatus === 'synced' && (
<span className="text-[11px] text-emerald-600 font-semibold">{t('autofix.kf5fec72a')}</span>
)}
{i18nSyncStatus === 'error' && (
<span className="text-[11px] text-rose-600 font-semibold">{t('autofix.k5321f8f0')}</span>
)}
</div>
<div className="flex flex-col gap-1 text-[11px] font-mono text-sky-800">
<span className="flex items-center gap-1.5">
<span className="text-sky-400 select-none">{t('autofix.k8aea9103')}</span>
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{subjectI18nKey(editor.template_type)}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-sky-400 select-none">{t('autofix.k0aa53382')}</span>
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{htmlI18nKey(editor.template_type)}</span>
</span>
</div>
</div>
)}
<label className="block md:col-span-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
<input
value={editor.subject}
onChange={(e) => setEditor((s) => ({ ...s, subject: e.target.value }))}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="Subject with placeholders like {{firstName}}"
/>
</label>
<label className="block md:col-span-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kb56e3ea8')}</div>
<textarea
value={editor.html_content}
onChange={(e) => setEditor((s) => ({ ...s, html_content: e.target.value }))}
className="min-h-[220px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder={t('autofix.k8735e9a4')}
/>
</label>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
<div className="mb-2 text-sm font-semibold text-slate-900">{t('autofix.kefc3a3f9')}</div>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<div className="mb-3 text-xs text-slate-500">
{t('autofix.k64efb463')}
<span className="font-medium text-slate-800">{editor.subject || t('autofix.k1b76fc38')}</span>
{selectedTemplate && !isCreating && (
<span className="ml-3">Last update: {new Date(selectedTemplate.updated_at).toLocaleString()}</span>
)}
</div>
<div
className="prose prose-sm max-w-none text-slate-800"
dangerouslySetInnerHTML={{ __html: editor.html_content || `<p class="text-slate-500">${t('autofix.k5201934d')}</p>` }}
/>
</div>
</div>
</div>
)}
</div>
</section>
);
}