Files
opencrm/frontend/src/components/email/EmailDetail.tsx
T

507 lines
19 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
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;
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,
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]);
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>
{/* 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">&lt;{email.fromAddress}&gt;</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>
{/* 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: email.htmlBody }}
/>
) : (
<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>
);
}