added backup and email client
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2 } 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';
|
||||
|
||||
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 = 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 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] });
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
{/* 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>
|
||||
{/* Löschen-Button:
|
||||
- In Vertragsansicht bei gesendeten E-Mails ausblenden
|
||||
- Bei automatisch zugeordneten E-Mails (aus Vertrag gesendet) ausblenden */}
|
||||
{!(isContractView && isSentFolder) && !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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user