Files
leocrm/frontend/src/components/mail/SignatureManager.tsx
T

214 lines
8.4 KiB
TypeScript
Raw Normal View History

/**
* Signature manager — CRUD for mail signatures with rich text editor.
* Supports placeholder variables for user/tenant data.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { RichTextEditor } from './RichTextEditor';
import { Loader2 } from 'lucide-react';
import {
fetchSignatures,
createSignature,
updateSignature,
deleteSignature,
type MailSignature,
} from '@/api/mail';
/** Available placeholder variables for signatures. */
const SIGNATURE_VARIABLES = [
{ token: '{{user.name}}', label: 'Vollständiger Name', description: 'Max Mustermann' },
{ token: '{{user.first_name}}', label: 'Vorname', description: 'Max' },
{ token: '{{user.last_name}}', label: 'Nachname', description: 'Mustermann' },
{ token: '{{user.email}}', label: 'E-Mail', description: 'max@firma.de' },
{ token: '{{user.role}}', label: 'Rolle', description: 'Admin' },
{ token: '{{tenant.name}}', label: 'Firma/Organisation', description: 'HMS Licht & Ton' },
{ token: '{{date}}', label: 'Aktuelles Datum', description: '16.07.2026' },
];
export function SignatureManager() {
const { t } = useTranslation();
const toast = useToast();
const [signatures, setSignatures] = useState<MailSignature[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<MailSignature | null>(null);
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [bodyHtml, setBodyHtml] = useState('');
const [isDefault, setIsDefault] = useState(false);
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<MailSignature | null>(null);
const load = useCallback(() => {
setLoading(true);
fetchSignatures()
.then((data) => {
setSignatures(data);
setError(null);
})
.catch((err: any) => {
setError(err?.message || err?.detail || (typeof err === 'string' ? err : 'Failed to load signatures'));
})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const handleNew = () => {
setEditing(null);
setName('');
setBodyHtml('');
setIsDefault(false);
setShowForm(true);
};
const handleEdit = (sig: MailSignature) => {
setEditing(sig);
setName(sig.name);
setBodyHtml(sig.body_html);
setIsDefault(sig.is_default);
setShowForm(true);
};
const handleSave = useCallback(async () => {
if (!name.trim()) return;
setSaving(true);
try {
if (editing) {
const updated = await updateSignature(editing.id, { name, body_html: bodyHtml, is_default: isDefault });
setSignatures((prev) => prev.map((s) => (s.id === editing.id ? updated : s)));
toast.success(t('mail.signatureUpdated'));
} else {
const created = await createSignature({ name, body_html: bodyHtml, is_default: isDefault });
setSignatures((prev) => [...prev, created]);
toast.success(t('mail.signatureCreated'));
}
setShowForm(false);
} catch (err: any) {
toast.error(err?.message || err?.detail || (typeof err === 'string' ? err : 'Save failed'));
} finally {
setSaving(false);
}
}, [editing, name, bodyHtml, isDefault, toast, t]);
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteSignature(deleteTarget.id);
setSignatures((prev) => prev.filter((s) => s.id !== deleteTarget.id));
toast.success(t('mail.signatureDeleted'));
setDeleteTarget(null);
} catch (err: any) {
toast.error(err?.message || err?.detail || (typeof err === 'string' ? err : 'Delete failed'));
}
}, [deleteTarget, toast, t]);
if (loading) {
return (
<div className="flex items-center justify-center py-8" data-testid="signature-manager-loading">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="signature-manager-error">
<p className="text-sm text-danger-700">{error}</p>
</div>
);
}
return (
<div data-testid="signature-manager">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.signatures')}</h3>
<Button size="sm" onClick={handleNew} data-testid="new-signature-btn">{t('mail.newSignature')}</Button>
</div>
{showForm && (
<Card className="mb-4" data-testid="signature-form">
<div className="space-y-3">
<Input
label={t('mail.signatureName')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('mail.signatureName')}
required
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.signatureBody')}</label>
{/* Placeholder variable buttons */}
<div className="flex flex-wrap gap-1 mb-2" data-testid="signature-variables">
{SIGNATURE_VARIABLES.map((v) => (
<button
key={v.token}
type="button"
onClick={() => setBodyHtml((prev) => `${prev}${v.token}`)}
className="inline-flex items-center px-2 py-0.5 text-xs rounded border border-secondary-300 bg-secondary-50 hover:bg-secondary-100 text-secondary-700"
title={v.description}
>
{v.label} <code className="ml-1 text-primary-600">{v.token}</code>
</button>
))}
</div>
<RichTextEditor
content={bodyHtml}
onChange={setBodyHtml}
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
/>
</div>
<label className="flex items-center gap-2 text-sm text-secondary-700">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
{t('mail.defaultSignature')}
</label>
<div className="flex gap-2">
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
</div>
</div>
</Card>
)}
{signatures.length === 0 && !showForm ? (
<EmptyState title={t('mail.noSignatures')} description={t('mail.noSignaturesDesc')} />
) : (
<div className="space-y-2" data-testid="signature-list">
{signatures.map((sig) => (
<Card key={sig.id}>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="font-medium text-secondary-900">{sig.name}</p>
{sig.is_default && <span className="text-xs text-primary-600">{t('mail.defaultSignature')}</span>}
<p className="text-sm text-secondary-500 mt-1 truncate" dangerouslySetInnerHTML={{ __html: sig.body_html }} />
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEdit(sig)} data-testid={`edit-signature-${sig.id}`}>{t('common.edit')}</Button>
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(sig)} data-testid={`delete-signature-${sig.id}`}>{t('common.delete')}</Button>
</div>
</div>
</Card>
))}
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
title={t('mail.deleteSignature')}
message={t('mail.confirmDeleteSignature')}
confirmLabel={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}