2fee13d09e
Klick auf die Vertragsnummer öffnet weiterhin im selben Tab (via React-Router Link). Neues Icon daneben öffnet den Vertrag in einem neuen Browser-Tab – analog zum Pro-Tab-Link in CustomerDetail.
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
||
import DOMPurify from 'dompurify';
|
||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import Button from '../ui/Button';
|
||
import { Link } from 'react-router-dom';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import toast from 'react-hot-toast';
|
||
import SaveAttachmentModal from './SaveAttachmentModal';
|
||
import SaveEmailAsPdfModal from './SaveEmailAsPdfModal';
|
||
|
||
interface EmailDetailProps {
|
||
email: CachedEmail;
|
||
onReply: () => void;
|
||
onForward?: () => void; // Weiterleiten (öffnet ComposeModal im Forward-Modus)
|
||
onResend?: () => void; // Erneut an Empfänger senden (One-Click-Resend)
|
||
onAssignContract: () => void;
|
||
onDeleted?: () => void; // Callback nach Löschen
|
||
isSentFolder?: boolean;
|
||
isContractView?: boolean; // True wenn aus der Vertragsansicht aufgerufen
|
||
isTrashView?: boolean; // True wenn aus dem Papierkorb
|
||
onRestored?: () => void; // Callback nach Wiederherstellen
|
||
accountId?: number; // Für folder-counts Invalidierung
|
||
}
|
||
|
||
export default function EmailDetail({
|
||
email,
|
||
onReply,
|
||
onForward,
|
||
onResend,
|
||
onAssignContract,
|
||
onDeleted,
|
||
isSentFolder: _isSentFolder = false,
|
||
isContractView = false,
|
||
isTrashView = false,
|
||
onRestored,
|
||
accountId,
|
||
}: EmailDetailProps) {
|
||
const [showHtml, setShowHtml] = useState(true);
|
||
const [localStarred, setLocalStarred] = useState(email.isStarred);
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
|
||
const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false);
|
||
const [saveAttachmentFilename, setSaveAttachmentFilename] = useState<string | null>(null);
|
||
const [showSaveAsPdfModal, setShowSaveAsPdfModal] = useState(false);
|
||
const queryClient = useQueryClient();
|
||
const { hasPermission } = useAuth();
|
||
|
||
// Sync localStarred wenn sich die Email ändert
|
||
useEffect(() => {
|
||
setLocalStarred(email.isStarred);
|
||
}, [email.id, email.isStarred]);
|
||
|
||
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
|
||
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
|
||
const safeHtmlBody = useMemo(() => {
|
||
if (!email.htmlBody) return '';
|
||
const sanitized = DOMPurify.sanitize(email.htmlBody, {
|
||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||
ADD_ATTR: ['target'],
|
||
});
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = sanitized;
|
||
wrapper.querySelectorAll('a').forEach((a) => {
|
||
a.setAttribute('target', '_blank');
|
||
a.setAttribute('rel', 'noopener noreferrer');
|
||
});
|
||
return wrapper.innerHTML;
|
||
}, [email.htmlBody]);
|
||
|
||
const toggleStarMutation = useMutation({
|
||
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
||
onMutate: () => {
|
||
// Optimistisches Update
|
||
setLocalStarred((prev) => !prev);
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||
queryClient.invalidateQueries({ queryKey: ['email', email.id] });
|
||
},
|
||
onError: () => {
|
||
// Bei Fehler zurücksetzen
|
||
setLocalStarred(email.isStarred);
|
||
},
|
||
});
|
||
|
||
const unassignMutation = useMutation({
|
||
mutationFn: () => cachedEmailApi.unassignFromContract(email.id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||
queryClient.invalidateQueries({ queryKey: ['email', email.id] });
|
||
if (email.contractId) {
|
||
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', email.contractId] });
|
||
}
|
||
toast.success('Vertragszuordnung aufgehoben');
|
||
},
|
||
onError: (error: Error) => {
|
||
console.error('Unassign error:', error);
|
||
toast.error(error.message || 'Fehler beim Aufheben der Zuordnung');
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: () => cachedEmailApi.delete(email.id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||
// Folder-Counts invalidieren
|
||
if (accountId) {
|
||
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
|
||
}
|
||
if (email.contractId) {
|
||
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', email.contractId] });
|
||
}
|
||
toast.success('E-Mail in Papierkorb verschoben');
|
||
setShowDeleteConfirm(false);
|
||
onDeleted?.();
|
||
},
|
||
onError: (error: Error) => {
|
||
console.error('Delete error:', error);
|
||
toast.error(error.message || 'Fehler beim Löschen der E-Mail');
|
||
setShowDeleteConfirm(false);
|
||
},
|
||
});
|
||
|
||
const restoreMutation = useMutation({
|
||
mutationFn: () => cachedEmailApi.restore(email.id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||
// Folder-Counts invalidieren
|
||
if (accountId) {
|
||
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
|
||
}
|
||
if (email.contractId) {
|
||
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', email.contractId] });
|
||
}
|
||
toast.success('E-Mail wiederhergestellt');
|
||
setShowRestoreConfirm(false);
|
||
onRestored?.();
|
||
},
|
||
onError: (error: Error) => {
|
||
console.error('Restore error:', error);
|
||
toast.error(error.message || 'Fehler beim Wiederherstellen der E-Mail');
|
||
setShowRestoreConfirm(false);
|
||
},
|
||
});
|
||
|
||
const permanentDeleteMutation = useMutation({
|
||
mutationFn: () => cachedEmailApi.permanentDelete(email.id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||
// Folder-Counts invalidieren (Trash count)
|
||
if (accountId) {
|
||
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
|
||
}
|
||
toast.success('E-Mail endgültig gelöscht');
|
||
setShowPermanentDeleteConfirm(false);
|
||
onDeleted?.();
|
||
},
|
||
onError: (error: Error) => {
|
||
console.error('Permanent delete error:', error);
|
||
toast.error(error.message || 'Fehler beim endgültigen Löschen der E-Mail');
|
||
setShowPermanentDeleteConfirm(false);
|
||
},
|
||
});
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
return new Date(dateStr).toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
const parseAddresses = (jsonStr: string): string[] => {
|
||
try {
|
||
return JSON.parse(jsonStr);
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const parseAttachments = (jsonStr?: string): string[] => {
|
||
if (!jsonStr) return [];
|
||
try {
|
||
return JSON.parse(jsonStr);
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const toAddresses = parseAddresses(email.toAddresses);
|
||
const ccAddresses = email.ccAddresses ? parseAddresses(email.ccAddresses) : [];
|
||
const attachments = parseAttachments(email.attachmentNames);
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Header */}
|
||
<div className="p-4 border-b border-gray-200 space-y-3">
|
||
{/* Subject & Actions */}
|
||
<div className="flex items-start justify-between gap-4">
|
||
<h2 className="text-lg font-semibold text-gray-900">
|
||
{email.subject || '(Kein Betreff)'}
|
||
</h2>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
{isTrashView ? (
|
||
<>
|
||
{/* Papierkorb-Aktionen */}
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setShowRestoreConfirm(true)}
|
||
title="Wiederherstellen"
|
||
>
|
||
<Undo2 className="w-4 h-4 mr-1" />
|
||
Wiederherstellen
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => setShowPermanentDeleteConfirm(true)}
|
||
title="Endgültig löschen"
|
||
>
|
||
<Trash2 className="w-4 h-4 mr-1" />
|
||
Endgültig löschen
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* Normale Aktionen */}
|
||
<button
|
||
onClick={() => toggleStarMutation.mutate()}
|
||
className={`p-2 rounded-lg hover:bg-gray-100 ${
|
||
localStarred ? 'text-yellow-500' : 'text-gray-400'
|
||
}`}
|
||
title={localStarred ? 'Stern entfernen' : 'Als wichtig markieren'}
|
||
>
|
||
<Star className={`w-5 h-5 ${localStarred ? 'fill-current' : ''}`} />
|
||
</button>
|
||
<Button variant="secondary" size="sm" onClick={onReply}>
|
||
<Reply className="w-4 h-4 mr-1" />
|
||
Antworten
|
||
</Button>
|
||
{onForward && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={onForward}
|
||
title="Diese E-Mail als neue Nachricht weiterleiten (Empfänger kann beliebig eingegeben werden, Inhalt + Header werden zitiert)"
|
||
>
|
||
<Forward className="w-4 h-4 mr-1" />
|
||
Weiterleiten
|
||
</Button>
|
||
)}
|
||
{onResend && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onResend}
|
||
title="Erneut an die ursprüngliche Empfänger-Adresse senden. Nützlich wenn die Stressfrei-Weiterleitungsadresse umgezogen ist – die Mail kommt dann an den aktuell hinterlegten Forward-Empfänger."
|
||
>
|
||
<RotateCcw className="w-4 h-4 mr-1" />
|
||
Erneut senden
|
||
</Button>
|
||
)}
|
||
{/* E-Mail als PDF speichern */}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowSaveAsPdfModal(true)}
|
||
title="E-Mail als PDF speichern"
|
||
>
|
||
<FileDown className="w-4 h-4" />
|
||
</Button>
|
||
{/* Löschen-Button nur für User mit emails:delete Permission */}
|
||
{hasPermission('emails:delete') && (
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => setShowDeleteConfirm(true)}
|
||
title="E-Mail löschen"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* From/To/Date */}
|
||
<div className="text-sm space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-500 w-12">Von:</span>
|
||
<span className="font-medium text-gray-900">
|
||
{email.fromName && `${email.fromName} `}
|
||
<span className="text-gray-600"><{email.fromAddress}></span>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-500 w-12">An:</span>
|
||
<span className="text-gray-700">{toAddresses.join(', ')}</span>
|
||
</div>
|
||
{ccAddresses.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-500 w-12">CC:</span>
|
||
<span className="text-gray-700">{ccAddresses.join(', ')}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-500 w-12">Am:</span>
|
||
<span className="text-gray-700">{formatDate(email.receivedAt)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Contract Assignment */}
|
||
<div className="flex items-center gap-2 pt-2">
|
||
{email.contract ? (
|
||
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 border border-green-200 rounded-lg">
|
||
<Link2 className="w-4 h-4 text-green-600" />
|
||
<span className="text-sm text-green-800">
|
||
Zugeordnet zu:{' '}
|
||
<Link
|
||
to={`/contracts/${email.contract.id}`}
|
||
className="font-medium hover:underline"
|
||
>
|
||
{email.contract.contractNumber}
|
||
</Link>
|
||
</span>
|
||
<a
|
||
href={`/contracts/${email.contract.id}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-1 text-green-600 hover:text-green-800"
|
||
title="Vertrag in neuem Tab öffnen"
|
||
aria-label="Vertrag in neuem Tab öffnen"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
</a>
|
||
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
||
{!email.isAutoAssigned && (
|
||
<button
|
||
onClick={() => unassignMutation.mutate()}
|
||
className="ml-2 p-1 hover:bg-green-100 rounded"
|
||
title="Zuordnung aufheben"
|
||
>
|
||
<X className="w-4 h-4 text-green-600" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* Zuordnen-Button: In Vertragsansicht ausblenden (E-Mails dort sind bereits zugeordnet) */
|
||
!isContractView && (
|
||
<Button variant="secondary" size="sm" onClick={onAssignContract}>
|
||
<Link2 className="w-4 h-4 mr-1" />
|
||
Vertrag zuordnen
|
||
</Button>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{/* Attachments */}
|
||
{attachments.length > 0 && (
|
||
<div className="pt-2">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Paperclip className="w-4 h-4 text-gray-400" />
|
||
<span className="text-sm text-gray-500">
|
||
{attachments.length} Anhang{attachments.length > 1 ? 'e' : ''}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{attachments.map((name, i) => (
|
||
<div
|
||
key={i}
|
||
className="inline-flex items-center gap-1 px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-700"
|
||
>
|
||
<span className="max-w-[200px] truncate mr-1">{name}</span>
|
||
<a
|
||
href={cachedEmailApi.getAttachmentUrl(email.id, name, true)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||
title={`${name} öffnen`}
|
||
>
|
||
<ExternalLink className="w-4 h-4 text-gray-500" />
|
||
</a>
|
||
<a
|
||
href={cachedEmailApi.getAttachmentUrl(email.id, name)}
|
||
download={name}
|
||
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||
title={`${name} herunterladen`}
|
||
>
|
||
<Download className="w-4 h-4 text-gray-500" />
|
||
</a>
|
||
{/* Speichern-Button (nicht im Papierkorb) */}
|
||
{!isTrashView && (
|
||
<button
|
||
onClick={() => setSaveAttachmentFilename(name)}
|
||
className="p-1 hover:bg-blue-100 rounded transition-colors"
|
||
title={`${name} speichern unter...`}
|
||
>
|
||
<Save className="w-4 h-4 text-blue-500" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Body Toggle */}
|
||
{email.htmlBody && email.textBody && (
|
||
<div className="px-4 py-2 border-b border-gray-200 flex items-center gap-2">
|
||
<button
|
||
onClick={() => setShowHtml(true)}
|
||
className={`px-3 py-1 text-sm rounded ${
|
||
showHtml ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
HTML
|
||
</button>
|
||
<button
|
||
onClick={() => setShowHtml(false)}
|
||
className={`px-3 py-1 text-sm rounded ${
|
||
!showHtml ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
Text
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Email Body */}
|
||
<div className="flex-1 overflow-auto p-4">
|
||
{showHtml && email.htmlBody ? (
|
||
<div
|
||
className="prose prose-sm max-w-none"
|
||
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
|
||
/>
|
||
) : (
|
||
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
||
{email.textBody || 'Kein Inhalt'}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
|
||
{/* Lösch-Bestätigung Modal (in Papierkorb verschieben) */}
|
||
{showDeleteConfirm && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||
E-Mail löschen?
|
||
</h3>
|
||
<p className="text-gray-600 mb-4">
|
||
Die E-Mail wird in den Papierkorb verschoben.
|
||
</p>
|
||
<div className="flex justify-end gap-3">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setShowDeleteConfirm(false)}
|
||
disabled={deleteMutation.isPending}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => deleteMutation.mutate()}
|
||
disabled={deleteMutation.isPending}
|
||
>
|
||
{deleteMutation.isPending ? 'Löschen...' : 'Löschen'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Wiederherstellen-Bestätigung Modal */}
|
||
{showRestoreConfirm && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||
E-Mail wiederherstellen?
|
||
</h3>
|
||
<p className="text-gray-600 mb-4">
|
||
Die E-Mail wird wieder in den ursprünglichen Ordner ({email.folder === 'SENT' ? 'Gesendet' : 'Posteingang'}) verschoben.
|
||
</p>
|
||
<div className="flex justify-end gap-3">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setShowRestoreConfirm(false)}
|
||
disabled={restoreMutation.isPending}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => restoreMutation.mutate()}
|
||
disabled={restoreMutation.isPending}
|
||
>
|
||
{restoreMutation.isPending ? 'Wird wiederhergestellt...' : 'Wiederherstellen'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Endgültig löschen-Bestätigung Modal */}
|
||
{showPermanentDeleteConfirm && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||
E-Mail endgültig löschen?
|
||
</h3>
|
||
<p className="text-gray-600 mb-4">
|
||
Diese Aktion kann nicht rückgängig gemacht werden. Die E-Mail wird unwiderruflich gelöscht.
|
||
</p>
|
||
<div className="flex justify-end gap-3">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setShowPermanentDeleteConfirm(false)}
|
||
disabled={permanentDeleteMutation.isPending}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => permanentDeleteMutation.mutate()}
|
||
disabled={permanentDeleteMutation.isPending}
|
||
>
|
||
{permanentDeleteMutation.isPending ? 'Wird gelöscht...' : 'Endgültig löschen'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Anhang speichern Modal */}
|
||
{saveAttachmentFilename && (
|
||
<SaveAttachmentModal
|
||
isOpen={true}
|
||
onClose={() => setSaveAttachmentFilename(null)}
|
||
emailId={email.id}
|
||
attachmentFilename={saveAttachmentFilename}
|
||
/>
|
||
)}
|
||
|
||
{/* E-Mail als PDF speichern Modal */}
|
||
{showSaveAsPdfModal && (
|
||
<SaveEmailAsPdfModal
|
||
isOpen={true}
|
||
onClose={() => setShowSaveAsPdfModal(false)}
|
||
emailId={email.id}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|