507 lines
19 KiB
TypeScript
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"><{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>
|
|
{/* 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>
|
|
);
|
|
}
|