added backup and email client

This commit is contained in:
2026-02-01 00:02:35 +01:00
parent ff857be01a
commit e4fdfbc95f
210 changed files with 24211 additions and 742 deletions
@@ -0,0 +1,174 @@
import { useState } from 'react';
import { Search, FileText } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, cachedEmailApi, CachedEmail } from '../../services/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
interface AssignToContractModalProps {
isOpen: boolean;
onClose: () => void;
email: CachedEmail;
customerId: number;
onSuccess?: () => void;
}
export default function AssignToContractModal({
isOpen,
onClose,
email,
customerId,
onSuccess,
}: AssignToContractModalProps) {
const [search, setSearch] = useState('');
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const queryClient = useQueryClient();
// Verträge des Kunden laden
const { data: contractsData, isLoading } = useQuery({
queryKey: ['contracts', 'customer', customerId],
queryFn: () => contractApi.getAll({ customerId }),
enabled: isOpen,
});
const contracts = contractsData?.data || [];
// Gefilterte Verträge
const filteredContracts = contracts.filter((contract) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
contract.contractNumber.toLowerCase().includes(searchLower) ||
contract.contractCategory?.name?.toLowerCase().includes(searchLower) ||
contract.provider?.name?.toLowerCase().includes(searchLower)
);
});
const assignMutation = useMutation({
mutationFn: (contractId: number) =>
cachedEmailApi.assignToContract(email.id, contractId),
onSuccess: (_data, contractId) => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['email', email.id] });
// Contract folder counts aktualisieren
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', contractId] });
onSuccess?.();
handleClose();
},
});
const handleClose = () => {
setSearch('');
setSelectedContractId(null);
onClose();
};
const handleAssign = () => {
if (selectedContractId) {
assignMutation.mutate(selectedContractId);
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE');
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="E-Mail Vertrag zuordnen"
size="lg"
>
<div className="space-y-4">
{/* Email Info */}
<div className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">
<span className="font-medium">Betreff:</span>{' '}
{email.subject || '(Kein Betreff)'}
</p>
<p className="text-sm text-gray-600">
<span className="font-medium">Von:</span> {email.fromAddress}
</p>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Vertrag suchen..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Contract List */}
<div className="border border-gray-200 rounded-lg max-h-80 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
) : filteredContracts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<FileText className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">Keine Verträge gefunden</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredContracts.map((contract) => (
<div
key={contract.id}
onClick={() => setSelectedContractId(contract.id)}
className={`
flex items-center gap-3 p-3 cursor-pointer transition-colors
${selectedContractId === contract.id ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'hover:bg-gray-50'}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">
{contract.contractNumber}
</span>
<span className={`
px-2 py-0.5 text-xs rounded-full
${contract.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
contract.status === 'PENDING' ? 'bg-yellow-100 text-yellow-800' :
contract.status === 'CANCELLED' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'}
`}>
{contract.status}
</span>
</div>
<div className="text-sm text-gray-600 truncate">
{contract.contractCategory?.name}
{contract.provider && ` - ${contract.provider.name}`}
</div>
<div className="text-xs text-gray-500">
Start: {formatDate(contract.startDate)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleAssign}
disabled={!selectedContractId || assignMutation.isPending}
>
{assignMutation.isPending ? 'Wird zugeordnet...' : 'Zuordnen'}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,340 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, FileText } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
import { useMutation } from '@tanstack/react-query';
interface ComposeEmailModalProps {
isOpen: boolean;
onClose: () => void;
account: MailboxAccount;
replyTo?: CachedEmail;
onSuccess?: () => void;
contractId?: number; // Optional: Vertrag dem die gesendete E-Mail zugeordnet wird
}
export default function ComposeEmailModal({
isOpen,
onClose,
account,
replyTo,
onSuccess,
contractId,
}: ComposeEmailModalProps) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Formular bei Modal-Öffnung initialisieren
useEffect(() => {
if (isOpen) {
if (replyTo) {
// Antwort: Felder vorausfüllen
setTo(replyTo.fromAddress || '');
// Betreff: "Re:" nur hinzufügen wenn nicht schon vorhanden
const existingSubject = replyTo.subject || '';
const hasRePrefix = /^(Re|Aw|Fwd|Wg):\s*/i.test(existingSubject);
setSubject(hasRePrefix ? existingSubject : `Re: ${existingSubject}`);
// Ursprüngliche Nachricht zitieren
const originalDate = new Date(replyTo.receivedAt).toLocaleString('de-DE');
const quotedText = replyTo.textBody
? `\n\n--- Ursprüngliche Nachricht ---\nVon: ${replyTo.fromName || replyTo.fromAddress}\nAm: ${originalDate}\n\n${replyTo.textBody}`
: '';
setBody(quotedText);
} else {
// Neue E-Mail: Felder leer
setTo('');
setSubject('');
setBody('');
}
setCc('');
setAttachments([]);
setError(null);
}
}, [isOpen, replyTo]);
// Maximale Dateigröße: 10 MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;
// Maximale Gesamtgröße aller Anhänge: 25 MB
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
// Datei zu Base64 konvertieren
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
// data:application/pdf;base64,JVBERi0... -> JVBERi0...
const result = reader.result as string;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
};
// Dateien hinzufügen
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newAttachments: EmailAttachment[] = [];
let currentTotalSize = attachments.reduce(
(sum, att) => sum + (att.content.length * 0.75), // Base64 ist ~33% größer
0
);
for (const file of Array.from(files)) {
// Einzelne Dateigröße prüfen
if (file.size > MAX_FILE_SIZE) {
setError(`Datei "${file.name}" ist zu groß (max. 10 MB)`);
continue;
}
// Gesamtgröße prüfen
if (currentTotalSize + file.size > MAX_TOTAL_SIZE) {
setError('Maximale Gesamtgröße der Anhänge erreicht (25 MB)');
break;
}
try {
const content = await fileToBase64(file);
newAttachments.push({
filename: file.name,
content,
contentType: file.type || 'application/octet-stream',
});
currentTotalSize += file.size;
} catch {
setError(`Fehler beim Lesen von "${file.name}"`);
}
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments]);
}
// Input zurücksetzen damit gleiche Datei erneut gewählt werden kann
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Anhang entfernen
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
// Dateigröße formatieren
const formatFileSize = (base64Content: string): string => {
const bytes = base64Content.length * 0.75; // Base64 Dekodierung
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const sendMutation = useMutation({
mutationFn: () =>
stressfreiEmailApi.sendEmail(account.id, {
to: to.split(',').map((e) => e.trim()).filter(Boolean),
cc: cc ? cc.split(',').map((e) => e.trim()).filter(Boolean) : undefined,
subject,
text: body,
inReplyTo: replyTo?.messageId,
references: replyTo?.messageId ? [replyTo.messageId] : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
contractId,
}),
onSuccess: () => {
onSuccess?.();
handleClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Senden');
},
});
const handleClose = () => {
// Formular wird beim nächsten Öffnen durch useEffect initialisiert
onClose();
};
const handleSend = () => {
if (!to.trim()) {
setError('Bitte Empfänger angeben');
return;
}
if (!subject.trim()) {
setError('Bitte Betreff angeben');
return;
}
setError(null);
sendMutation.mutate();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={replyTo ? 'Antworten' : 'Neue E-Mail'}
size="lg"
>
<div className="space-y-4">
{/* From */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Von
</label>
<div className="px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-700">
{account.email}
</div>
</div>
{/* To */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
An <span className="text-red-500">*</span>
</label>
<input
type="text"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="empfaenger@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-500">
Mehrere Empfänger mit Komma trennen
</p>
</div>
{/* CC */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CC
</label>
<input
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betreff <span className="text-red-500">*</span>
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Betreff eingeben"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Body */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nachricht
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
placeholder="Ihre Nachricht..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
{/* Anhänge */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anhänge
</label>
{/* Versteckter File-Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
multiple
className="hidden"
/>
{/* Anhang hinzufügen Button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Paperclip className="w-4 h-4 mr-2" />
Datei anhängen
</button>
{/* Anhang-Liste */}
{attachments.length > 0 && (
<div className="mt-2 space-y-2">
{attachments.map((att, index) => (
<div
key={index}
className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg"
>
<div className="flex items-center min-w-0">
<FileText className="w-4 h-4 text-gray-500 mr-2 flex-shrink-0" />
<span className="text-sm text-gray-700 truncate">
{att.filename}
</span>
<span className="ml-2 text-xs text-gray-500 flex-shrink-0">
({formatFileSize(att.content)})
</span>
</div>
<button
type="button"
onClick={() => removeAttachment(index)}
className="ml-2 p-1 text-gray-400 hover:text-red-500 transition-colors"
title="Anhang entfernen"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<p className="mt-1 text-xs text-gray-500">
Max. 10 MB pro Datei, 25 MB gesamt
</p>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleSend}
disabled={sendMutation.isPending}
>
<Send className="w-4 h-4 mr-2" />
{sendMutation.isPending ? 'Wird gesendet...' : 'Senden'}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,391 @@
import { useState } from 'react';
import { Mail, MailOpen, Star, Paperclip, Plus, X, ChevronRight, Inbox, Send } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cachedEmailApi, CachedEmail } from '../../services/api';
import Button from '../ui/Button';
import Card from '../ui/Card';
import EmailDetail from './EmailDetail';
import ComposeEmailModal from './ComposeEmailModal';
type EmailFolder = 'INBOX' | 'SENT';
interface ContractEmailsSectionProps {
contractId: number;
customerId: number;
}
export default function ContractEmailsSection({
contractId,
customerId,
}: ContractEmailsSectionProps) {
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
const [showCompose, setShowCompose] = useState(false);
const [replyToEmail, setReplyToEmail] = useState<CachedEmail | null>(null);
const queryClient = useQueryClient();
// E-Mails für den Vertrag laden (nach Ordner gefiltert)
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
queryKey: ['emails', 'contract', contractId, selectedFolder],
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder }),
});
const emails = emailsData?.data || [];
// Ordner-Anzahlen für Badges
const { data: folderCountsData } = useQuery({
queryKey: ['contract-folder-counts', contractId],
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
});
const folderCounts = folderCountsData?.data || {
inbox: 0,
inboxUnread: 0,
sent: 0,
sentUnread: 0,
};
// Mailbox-Konten für Versand laden
const { data: accountsData } = useQuery({
queryKey: ['mailbox-accounts', customerId],
queryFn: () => cachedEmailApi.getMailboxAccounts(customerId),
});
const accounts = accountsData?.data || [];
const firstAccount = accounts[0];
// Einzelne E-Mail laden (mit Body)
const { data: emailDetailData } = useQuery({
queryKey: ['email', selectedEmail?.id],
queryFn: () => cachedEmailApi.getById(selectedEmail!.id),
enabled: !!selectedEmail?.id,
});
const emailDetail = emailDetailData?.data || selectedEmail;
// Stern umschalten
const toggleStarMutation = useMutation({
mutationFn: (emailId: number) => cachedEmailApi.toggleStar(emailId),
onSuccess: (_data, emailId) => {
queryClient.invalidateQueries({ queryKey: ['emails', 'contract', contractId] });
queryClient.invalidateQueries({ queryKey: ['email', emailId] });
},
});
// Als gelesen/ungelesen markieren
const toggleReadMutation = useMutation({
mutationFn: ({ emailId, isRead }: { emailId: number; isRead: boolean }) =>
cachedEmailApi.markAsRead(emailId, isRead),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['emails', 'contract', contractId] });
queryClient.invalidateQueries({ queryKey: ['email', variables.emailId] });
// Folder-Counts aktualisieren für Badge-Update
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', contractId] });
},
});
// Zuordnung aufheben
const unassignMutation = useMutation({
mutationFn: (emailId: number) => cachedEmailApi.unassignFromContract(emailId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails', 'contract', contractId] });
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', contractId] });
setSelectedEmail(null);
},
});
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
};
const handleStarClick = (e: React.MouseEvent, emailId: number) => {
e.stopPropagation();
toggleStarMutation.mutate(emailId);
};
const handleReadToggle = (e: React.MouseEvent, email: CachedEmail) => {
e.stopPropagation();
toggleReadMutation.mutate({ emailId: email.id, isRead: !email.isRead });
};
const handleSelectEmail = (email: CachedEmail) => {
// E-Mail als gelesen markieren wenn noch nicht gelesen
if (!email.isRead) {
toggleReadMutation.mutate({ emailId: email.id, isRead: true });
}
setSelectedEmail(email);
};
const handleReply = () => {
setReplyToEmail(emailDetail || null);
setShowCompose(true);
};
const handleNewEmail = () => {
setReplyToEmail(null);
setShowCompose(true);
};
const handleUnassign = (e: React.MouseEvent, emailId: number) => {
e.stopPropagation();
if (selectedEmail?.id === emailId) {
setSelectedEmail(null);
}
unassignMutation.mutate(emailId);
};
const handleFolderChange = (folder: EmailFolder) => {
setSelectedFolder(folder);
setSelectedEmail(null);
};
// Für gesendete E-Mails: Empfänger extrahieren
const getDisplayName = (email: CachedEmail) => {
if (selectedFolder === 'SENT') {
try {
const toAddresses = JSON.parse(email.toAddresses);
if (toAddresses.length > 0) {
return `An: ${toAddresses[0]}${toAddresses.length > 1 ? ` (+${toAddresses.length - 1})` : ''}`;
}
} catch {
return 'An: (Unbekannt)';
}
}
return email.fromName || email.fromAddress;
};
return (
<Card
title={
<div className="flex items-center gap-4">
<span>E-Mails</span>
{/* Folder Tabs */}
<div className="flex items-center gap-1 bg-gray-200 rounded-lg p-1">
<button
onClick={() => handleFolderChange('INBOX')}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md transition-colors ${
selectedFolder === 'INBOX'
? 'bg-white text-blue-600 shadow-sm font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Inbox className="w-3.5 h-3.5" />
Empfangen
{folderCounts.inbox > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
folderCounts.inboxUnread > 0
? 'bg-blue-100 text-blue-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${folderCounts.inboxUnread} ungelesen / ${folderCounts.inbox} gesamt`}
>
{folderCounts.inboxUnread > 0
? `${folderCounts.inboxUnread}/${folderCounts.inbox}`
: folderCounts.inbox}
</span>
)}
</button>
<button
onClick={() => handleFolderChange('SENT')}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md transition-colors ${
selectedFolder === 'SENT'
? 'bg-white text-blue-600 shadow-sm font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Send className="w-3.5 h-3.5" />
Gesendet
{folderCounts.sent > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
folderCounts.sentUnread > 0
? 'bg-blue-100 text-blue-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${folderCounts.sentUnread} ungelesen / ${folderCounts.sent} gesamt`}
>
{folderCounts.sentUnread > 0
? `${folderCounts.sentUnread}/${folderCounts.sent}`
: folderCounts.sent}
</span>
)}
</button>
</div>
</div>
}
actions={
firstAccount && (
<Button variant="secondary" size="sm" onClick={handleNewEmail}>
<Plus className="w-4 h-4 mr-1" />
Neue E-Mail
</Button>
)
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
) : emails.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<Mail className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">
{selectedFolder === 'INBOX'
? 'Keine E-Mails zugeordnet'
: 'Keine E-Mails über diesen Vertrag gesendet'}
</p>
{selectedFolder === 'INBOX' && (
<p className="text-xs mt-1">
E-Mails können im E-Mail-Tab des Kunden zugeordnet werden
</p>
)}
</div>
) : (
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
{/* Email List */}
<div className="w-2/5 border-r border-gray-200 overflow-auto">
<div className="divide-y divide-gray-200">
{emails.map((email) => (
<div
key={email.id}
onClick={() => handleSelectEmail(email)}
className={[
'flex items-start gap-2 p-3 cursor-pointer transition-colors',
selectedEmail?.id === email.id
? 'bg-blue-100'
: ['hover:bg-gray-100', !email.isRead ? 'bg-white' : 'bg-gray-50/50'].join(' ')
].join(' ')}
style={{
borderLeft: selectedEmail?.id === email.id ? '4px solid #2563eb' : '4px solid transparent'
}}
>
{/* Read Status */}
<button
onClick={(e) => handleReadToggle(e, email)}
className={`
flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-gray-200
${!email.isRead ? 'text-blue-600' : 'text-gray-400'}
`}
title={email.isRead ? 'Als ungelesen markieren' : 'Als gelesen markieren'}
>
{email.isRead ? (
<MailOpen className="w-4 h-4" />
) : (
<Mail className="w-4 h-4" />
)}
</button>
{/* Star */}
<button
onClick={(e) => handleStarClick(e, email.id)}
className={`
flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-gray-200
${email.isStarred ? 'text-yellow-500' : 'text-gray-400'}
`}
title={email.isStarred ? 'Stern entfernen' : 'Als wichtig markieren'}
>
<Star className={`w-4 h-4 ${email.isStarred ? 'fill-current' : ''}`} />
</button>
{/* Email Content */}
<div className="flex-1 min-w-0">
{/* From/To & Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className={`text-sm truncate ${!email.isRead ? 'font-semibold text-gray-900' : 'text-gray-700'}`}>
{getDisplayName(email)}
</span>
<span className="text-xs text-gray-500 flex-shrink-0">
{formatDate(email.receivedAt)}
</span>
</div>
{/* Subject */}
<div className="flex items-center gap-2">
<span className={`text-sm truncate ${!email.isRead ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{email.subject || '(Kein Betreff)'}
</span>
{email.hasAttachments && (
<Paperclip className="w-3 h-3 text-gray-400 flex-shrink-0" />
)}
</div>
</div>
{/* Unassign Button - für empfangene E-Mails und manuell zugeordnete gesendete E-Mails */}
{(selectedFolder === 'INBOX' || (selectedFolder === 'SENT' && !email.isAutoAssigned)) && (
<button
onClick={(e) => handleUnassign(e, email.id)}
className="flex-shrink-0 mt-1 p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Zuordnung aufheben"
>
<X className="w-4 h-4" />
</button>
)}
{/* Chevron */}
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0 mt-2" />
</div>
))}
</div>
</div>
{/* Email Detail */}
<div className="flex-1 overflow-auto">
{emailDetail && selectedEmail ? (
<EmailDetail
email={emailDetail}
onReply={handleReply}
onAssignContract={() => {}}
onDeleted={() => {
setSelectedEmail(null);
// Folder-Counts aktualisieren
queryClient.invalidateQueries({ queryKey: ['emails', 'contract', contractId] });
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', contractId] });
}}
isSentFolder={selectedFolder === 'SENT'}
isContractView={true}
accountId={emailDetail?.stressfreiEmailId}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Mail className="w-12 h-12 mb-2 opacity-30" />
<p>Wählen Sie eine E-Mail aus</p>
</div>
)}
</div>
</div>
)}
{/* Compose Modal */}
{firstAccount && (
<ComposeEmailModal
isOpen={showCompose}
onClose={() => {
setShowCompose(false);
setReplyToEmail(null);
}}
account={firstAccount}
replyTo={replyToEmail || undefined}
contractId={contractId}
onSuccess={() => {
// Gesendete E-Mails im Vertrag aktualisieren
queryClient.invalidateQueries({ queryKey: ['emails', 'contract', contractId, 'SENT'] });
// Folder-Counts aktualisieren
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', contractId] });
// Falls wir im Gesendet-Ordner sind, Liste neu laden
if (selectedFolder === 'SENT') {
refetchEmails();
}
}}
/>
)}
</Card>
);
}
@@ -0,0 +1,394 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Button from '../ui/Button';
import EmailList from './EmailList';
import EmailDetail from './EmailDetail';
import ComposeEmailModal from './ComposeEmailModal';
import AssignToContractModal from './AssignToContractModal';
import TrashEmailList from './TrashEmailList';
type EmailFolder = 'INBOX' | 'SENT' | 'TRASH';
interface EmailClientTabProps {
customerId: number;
}
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
const [showCompose, setShowCompose] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const [replyToEmail, setReplyToEmail] = useState<CachedEmail | null>(null);
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const canAccessTrash = hasPermission('emails:delete');
// Mailbox-Konten laden
const { data: accountsData, isLoading: accountsLoading } = useQuery({
queryKey: ['mailbox-accounts', customerId],
queryFn: () => cachedEmailApi.getMailboxAccounts(customerId),
});
const accounts = accountsData?.data || [];
// Erstes Konto automatisch auswählen
useEffect(() => {
if (accounts.length > 0 && !selectedAccountId) {
setSelectedAccountId(accounts[0].id);
}
}, [accounts, selectedAccountId]);
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
// E-Mails laden (nur für INBOX und SENT)
const { data: emailsData, isLoading: emailsLoading, refetch: refetchEmails } = useQuery({
queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder],
queryFn: () =>
cachedEmailApi.getForCustomer(customerId, {
accountId: selectedAccountId || undefined,
folder: selectedFolder as 'INBOX' | 'SENT',
}),
enabled: !!selectedAccountId && selectedFolder !== 'TRASH',
});
const emails = emailsData?.data || [];
// Papierkorb-E-Mails laden
const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId],
queryFn: () => cachedEmailApi.getTrash(customerId),
enabled: selectedFolder === 'TRASH' && canAccessTrash,
});
const trashEmails = trashData?.data || [];
// Ordner-Anzahlen für Badges (total und ungelesen pro Ordner)
const { data: folderCountsData } = useQuery({
queryKey: ['folder-counts', selectedAccountId],
queryFn: () => stressfreiEmailApi.getFolderCounts(selectedAccountId!),
enabled: !!selectedAccountId,
});
const folderCounts = folderCountsData?.data || {
inbox: 0,
inboxUnread: 0,
sent: 0,
sentUnread: 0,
trash: 0,
trashUnread: 0,
};
// Einzelne E-Mail laden (mit Body)
const { data: emailDetailData } = useQuery({
queryKey: ['email', selectedEmail?.id],
queryFn: () => cachedEmailApi.getById(selectedEmail!.id),
enabled: !!selectedEmail?.id,
});
const emailDetail = emailDetailData?.data || selectedEmail;
// Synchronisation
const syncMutation = useMutation({
mutationFn: (accountId: number) => stressfreiEmailApi.syncEmails(accountId),
onSuccess: () => {
// E-Mail-Listen neu laden
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Ordner-Anzahlen aktualisieren
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
// Mailbox-Accounts aktualisieren
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
},
});
const handleSync = () => {
if (selectedAccountId) {
syncMutation.mutate(selectedAccountId);
}
};
const handleSelectEmail = (email: CachedEmail) => {
setSelectedEmail(email);
};
const handleReply = () => {
setReplyToEmail(emailDetail || null);
setShowCompose(true);
};
const handleNewEmail = () => {
setReplyToEmail(null);
setShowCompose(true);
};
const handleAssignContract = () => {
setShowAssign(true);
};
// Keine Mailbox-Konten vorhanden
if (!accountsLoading && accounts.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<Mail className="w-16 h-16 mb-4 opacity-30" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Keine E-Mail-Konten vorhanden
</h3>
<p className="text-sm text-center max-w-md">
Erstellen Sie eine Stressfrei-Wechseln E-Mail-Adresse mit aktivierter Mailbox,
um E-Mails hier empfangen und versenden zu können.
</p>
</div>
);
}
const handleFolderChange = (folder: EmailFolder) => {
setSelectedFolder(folder);
setSelectedEmail(null);
};
return (
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
{/* Header */}
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
{/* Account Selector */}
<div className="flex items-center gap-3">
<Inbox className="w-5 h-5 text-gray-500" />
<select
value={selectedAccountId || ''}
onChange={(e) => {
setSelectedAccountId(Number(e.target.value));
setSelectedEmail(null);
}}
className="px-3 py-2 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.email}
</option>
))}
</select>
</div>
{/* Folder Tabs */}
<div className="flex items-center gap-1 bg-gray-200 rounded-lg p-1">
<button
onClick={() => handleFolderChange('INBOX')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
selectedFolder === 'INBOX'
? 'bg-white text-blue-600 shadow-sm font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Inbox className="w-4 h-4" />
Posteingang
{folderCounts.inbox > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
folderCounts.inboxUnread > 0
? 'bg-blue-100 text-blue-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${folderCounts.inboxUnread} ungelesen / ${folderCounts.inbox} gesamt`}
>
{folderCounts.inboxUnread > 0
? `${folderCounts.inboxUnread}/${folderCounts.inbox}`
: folderCounts.inbox}
</span>
)}
</button>
<button
onClick={() => handleFolderChange('SENT')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
selectedFolder === 'SENT'
? 'bg-white text-blue-600 shadow-sm font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Send className="w-4 h-4" />
Gesendet
{folderCounts.sent > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
folderCounts.sentUnread > 0
? 'bg-blue-100 text-blue-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${folderCounts.sentUnread} ungelesen / ${folderCounts.sent} gesamt`}
>
{folderCounts.sentUnread > 0
? `${folderCounts.sentUnread}/${folderCounts.sent}`
: folderCounts.sent}
</span>
)}
</button>
{canAccessTrash && (
<button
onClick={() => handleFolderChange('TRASH')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
selectedFolder === 'TRASH'
? 'bg-white text-red-600 shadow-sm font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Trash2 className="w-4 h-4" />
Papierkorb
{folderCounts.trash > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
folderCounts.trashUnread > 0
? 'bg-red-100 text-red-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
>
{folderCounts.trashUnread > 0
? `${folderCounts.trashUnread}/${folderCounts.trash}`
: folderCounts.trash}
</span>
)}
</button>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{selectedFolder !== 'TRASH' && (
<Button
variant="secondary"
size="sm"
onClick={handleSync}
disabled={syncMutation.isPending || !selectedAccountId}
>
<RefreshCw className={`w-4 h-4 mr-1 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
{syncMutation.isPending ? 'Sync...' : 'Synchronisieren'}
</Button>
)}
<Button
size="sm"
onClick={handleNewEmail}
disabled={!selectedAccount}
>
<Plus className="w-4 h-4 mr-1" />
Neue E-Mail
</Button>
</div>
</div>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
{/* Email List */}
<div className="w-1/3 border-r border-gray-200 overflow-auto">
{selectedFolder === 'TRASH' ? (
<TrashEmailList
emails={trashEmails}
selectedEmailId={selectedEmail?.id}
onSelectEmail={handleSelectEmail}
onEmailRestored={(emailId) => {
if (selectedEmail?.id === emailId) {
setSelectedEmail(null);
}
// Trash und normale E-Mails neu laden + Folder-Counts aktualisieren
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
}}
onEmailDeleted={(emailId) => {
if (selectedEmail?.id === emailId) {
setSelectedEmail(null);
}
queryClient.invalidateQueries({ queryKey: ['emails', 'trash'] });
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
}}
isLoading={trashLoading}
/>
) : (
<EmailList
emails={emails}
selectedEmailId={selectedEmail?.id}
onSelectEmail={handleSelectEmail}
onEmailDeleted={(emailId) => {
// Falls die gelöschte E-Mail ausgewählt war, Auswahl aufheben
if (selectedEmail?.id === emailId) {
setSelectedEmail(null);
}
// Folder-Counts aktualisieren (Trash-Counter ist jetzt dort)
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
}}
isLoading={emailsLoading}
folder={selectedFolder as 'INBOX' | 'SENT'}
accountId={selectedAccountId}
/>
)}
</div>
{/* Email Detail */}
<div className="flex-1 overflow-auto">
{emailDetail ? (
<EmailDetail
email={emailDetail}
onReply={handleReply}
onAssignContract={handleAssignContract}
onDeleted={() => {
setSelectedEmail(null);
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
}}
isSentFolder={selectedFolder === 'SENT'}
isTrashView={selectedFolder === 'TRASH'}
onRestored={() => {
setSelectedEmail(null);
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
}}
accountId={selectedAccountId || undefined}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Mail className="w-12 h-12 mb-2 opacity-30" />
<p>Wählen Sie eine E-Mail aus</p>
</div>
)}
</div>
</div>
{/* Compose Modal */}
{selectedAccount && (
<ComposeEmailModal
isOpen={showCompose}
onClose={() => {
setShowCompose(false);
setReplyToEmail(null);
}}
account={selectedAccount}
replyTo={replyToEmail || undefined}
onSuccess={() => {
// Gesendete E-Mails aktualisieren
queryClient.invalidateQueries({ queryKey: ['emails', 'customer', customerId, selectedAccountId, 'SENT'] });
// Folder-Counts aktualisieren
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
// Falls wir im Gesendet-Ordner sind, Liste neu laden
if (selectedFolder === 'SENT') {
refetchEmails();
}
}}
/>
)}
{/* Assign Contract Modal */}
{emailDetail && (
<AssignToContractModal
isOpen={showAssign}
onClose={() => setShowAssign(false)}
email={emailDetail}
customerId={customerId}
onSuccess={() => {
refetchEmails();
}}
/>
)}
</div>
);
}
@@ -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">&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>
{/* 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>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useState } from 'react';
import { Mail, MailOpen, Star, Paperclip, ChevronRight, Trash2 } from 'lucide-react';
import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../context/AuthContext';
import Button from '../ui/Button';
import toast from 'react-hot-toast';
interface EmailListProps {
emails: CachedEmail[];
selectedEmailId?: number;
onSelectEmail: (email: CachedEmail) => void;
onEmailDeleted?: (emailId: number) => void;
isLoading?: boolean;
folder?: 'INBOX' | 'SENT';
accountId?: number | null; // Für Folder-Count-Aktualisierung bei Lesen/Ungelesen
}
export default function EmailList({
emails,
selectedEmailId,
onSelectEmail,
onEmailDeleted,
isLoading,
folder = 'INBOX',
accountId,
}: EmailListProps) {
const isSentFolder = folder === 'SENT';
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
const { hasPermission } = useAuth();
// Für gesendete E-Mails: Empfänger extrahieren
const getDisplayName = (email: CachedEmail) => {
if (isSentFolder) {
// Bei gesendeten E-Mails: Ersten Empfänger anzeigen
try {
const toAddresses = JSON.parse(email.toAddresses);
if (toAddresses.length > 0) {
return `An: ${toAddresses[0]}${toAddresses.length > 1 ? ` (+${toAddresses.length - 1})` : ''}`;
}
} catch {
return 'An: (Unbekannt)';
}
}
// Bei empfangenen E-Mails: Absender anzeigen
return email.fromName || email.fromAddress;
};
const queryClient = useQueryClient();
const toggleStarMutation = useMutation({
mutationFn: (emailId: number) => cachedEmailApi.toggleStar(emailId),
onSuccess: (_data, emailId) => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['email', emailId] });
},
});
const toggleReadMutation = useMutation({
mutationFn: ({ emailId, isRead }: { emailId: number; isRead: boolean }) =>
cachedEmailApi.markAsRead(emailId, isRead),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['email', variables.emailId] });
// Folder-Counts aktualisieren für Badge-Update
if (accountId) {
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
}
},
});
const deleteMutation = useMutation({
mutationFn: (emailId: number) => cachedEmailApi.delete(emailId),
onSuccess: (_data, emailId) => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Folder-Counts aktualisieren
if (accountId) {
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
}
toast.success('E-Mail in Papierkorb verschoben');
setDeleteConfirmId(null);
onEmailDeleted?.(emailId);
},
onError: (error: Error) => {
console.error('Delete error:', error);
toast.error(error.message || 'Fehler beim Löschen der E-Mail');
setDeleteConfirmId(null);
},
});
const handleDeleteClick = (e: React.MouseEvent, emailId: number) => {
e.stopPropagation();
setDeleteConfirmId(emailId);
};
const handleDeleteConfirm = (e: React.MouseEvent) => {
e.stopPropagation();
if (deleteConfirmId) {
deleteMutation.mutate(deleteConfirmId);
}
};
const handleDeleteCancel = (e: React.MouseEvent) => {
e.stopPropagation();
setDeleteConfirmId(null);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
};
const handleStarClick = (e: React.MouseEvent, emailId: number) => {
e.stopPropagation();
toggleStarMutation.mutate(emailId);
};
const handleReadToggle = (e: React.MouseEvent, email: CachedEmail) => {
e.stopPropagation();
toggleReadMutation.mutate({ emailId: email.id, isRead: !email.isRead });
};
const handleSelectEmail = (email: CachedEmail) => {
// E-Mail als gelesen markieren wenn noch nicht gelesen
if (!email.isRead) {
toggleReadMutation.mutate({ emailId: email.id, isRead: true });
}
onSelectEmail(email);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (emails.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Mail className="w-12 h-12 mb-2 opacity-50" />
<p>Keine E-Mails vorhanden</p>
</div>
);
}
return (
<div className="divide-y divide-gray-200">
{emails.map((email) => (
<div
key={email.id}
onClick={() => handleSelectEmail(email)}
className={[
'flex items-start gap-3 p-3 cursor-pointer transition-colors',
selectedEmailId === email.id
? 'bg-blue-100'
: ['hover:bg-gray-100', !email.isRead ? 'bg-white' : 'bg-gray-50/50'].join(' ')
].join(' ')}
style={{
borderLeft: selectedEmailId === email.id ? '4px solid #2563eb' : '4px solid transparent'
}}
>
{/* Read Status */}
<button
onClick={(e) => handleReadToggle(e, email)}
className={`
flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-gray-200
${!email.isRead ? 'text-blue-600' : 'text-gray-400'}
`}
title={email.isRead ? 'Als ungelesen markieren' : 'Als gelesen markieren'}
>
{email.isRead ? (
<MailOpen className="w-4 h-4" />
) : (
<Mail className="w-4 h-4" />
)}
</button>
{/* Star */}
<button
onClick={(e) => handleStarClick(e, email.id)}
className={`
flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-gray-200
${email.isStarred ? 'text-yellow-500' : 'text-gray-400'}
`}
title={email.isStarred ? 'Stern entfernen' : 'Als wichtig markieren'}
>
<Star className={`w-4 h-4 ${email.isStarred ? 'fill-current' : ''}`} />
</button>
{/* Delete (nur für User mit emails:delete Permission) */}
{hasPermission('emails:delete') && (
<button
onClick={(e) => handleDeleteClick(e, email.id)}
className="flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600"
title="E-Mail löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{/* Email Content */}
<div className="flex-1 min-w-0">
{/* From/To & Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className={`text-sm truncate ${!email.isRead ? 'font-semibold text-gray-900' : 'text-gray-700'}`}>
{getDisplayName(email)}
</span>
<span className="text-xs text-gray-500 flex-shrink-0">
{formatDate(email.receivedAt)}
</span>
</div>
{/* Subject */}
<div className="flex items-center gap-2">
<span className={`text-sm truncate ${!email.isRead ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{email.subject || '(Kein Betreff)'}
</span>
{email.hasAttachments && (
<Paperclip className="w-3 h-3 text-gray-400 flex-shrink-0" />
)}
</div>
{/* Contract Badge */}
{email.contract && (
<div className="mt-1">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
{email.contract.contractNumber}
</span>
</div>
)}
</div>
{/* Chevron */}
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0 mt-2" />
</div>
))}
{/* Lösch-Bestätigung Modal */}
{deleteConfirmId && (
<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={handleDeleteCancel}
disabled={deleteMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Löschen...' : 'Löschen'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,248 @@
import { useState } from 'react';
import { Undo2, Trash2, ChevronRight, Inbox, Send } from 'lucide-react';
import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import Button from '../ui/Button';
import toast from 'react-hot-toast';
interface TrashEmailListProps {
emails: CachedEmail[];
selectedEmailId?: number;
onSelectEmail: (email: CachedEmail) => void;
onEmailRestored?: (emailId: number) => void;
onEmailDeleted?: (emailId: number) => void;
isLoading?: boolean;
}
export default function TrashEmailList({
emails,
selectedEmailId,
onSelectEmail,
onEmailRestored,
onEmailDeleted,
isLoading,
}: TrashEmailListProps) {
const [actionConfirmId, setActionConfirmId] = useState<number | null>(null);
const [actionType, setActionType] = useState<'restore' | 'delete' | null>(null);
const queryClient = useQueryClient();
// Absender/Empfänger anzeigen
const getDisplayName = (email: CachedEmail) => {
if (email.folder === 'SENT') {
try {
const toAddresses = JSON.parse(email.toAddresses);
if (toAddresses.length > 0) {
return `An: ${toAddresses[0]}${toAddresses.length > 1 ? ` (+${toAddresses.length - 1})` : ''}`;
}
} catch {
return 'An: (Unbekannt)';
}
}
return email.fromName || email.fromAddress;
};
const restoreMutation = useMutation({
mutationFn: (emailId: number) => cachedEmailApi.restore(emailId),
onSuccess: (_data, emailId) => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
toast.success('E-Mail wiederhergestellt');
setActionConfirmId(null);
setActionType(null);
onEmailRestored?.(emailId);
},
onError: (error: Error) => {
console.error('Restore error:', error);
toast.error(error.message || 'Fehler beim Wiederherstellen');
setActionConfirmId(null);
setActionType(null);
},
});
const deleteMutation = useMutation({
mutationFn: (emailId: number) => cachedEmailApi.permanentDelete(emailId),
onSuccess: (_data, emailId) => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
toast.success('E-Mail endgültig gelöscht');
setActionConfirmId(null);
setActionType(null);
onEmailDeleted?.(emailId);
},
onError: (error: Error) => {
console.error('Permanent delete error:', error);
toast.error(error.message || 'Fehler beim endgültigen Löschen');
setActionConfirmId(null);
setActionType(null);
},
});
const handleRestoreClick = (e: React.MouseEvent, emailId: number) => {
e.stopPropagation();
setActionConfirmId(emailId);
setActionType('restore');
};
const handleDeleteClick = (e: React.MouseEvent, emailId: number) => {
e.stopPropagation();
setActionConfirmId(emailId);
setActionType('delete');
};
const handleConfirm = (e: React.MouseEvent) => {
e.stopPropagation();
if (actionConfirmId && actionType) {
if (actionType === 'restore') {
restoreMutation.mutate(actionConfirmId);
} else {
deleteMutation.mutate(actionConfirmId);
}
}
};
const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation();
setActionConfirmId(null);
setActionType(null);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
};
const formatDeletedAt = (dateStr?: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return `Gelöscht um ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
}
return `Gelöscht am ${date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
</div>
);
}
if (emails.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Trash2 className="w-12 h-12 mb-2 opacity-50" />
<p>Papierkorb ist leer</p>
</div>
);
}
return (
<div className="divide-y divide-gray-200">
{emails.map((email) => (
<div
key={email.id}
onClick={() => onSelectEmail(email)}
className={[
'flex items-start gap-3 p-3 cursor-pointer transition-colors',
selectedEmailId === email.id
? 'bg-red-100'
: 'hover:bg-gray-100 bg-gray-50/50'
].join(' ')}
style={{
borderLeft: selectedEmailId === email.id ? '4px solid #dc2626' : '4px solid transparent'
}}
>
{/* Folder Icon */}
<div className="flex-shrink-0 mt-1 p-1 -ml-1 text-gray-400" title={email.folder === 'SENT' ? 'Aus Gesendet' : 'Aus Posteingang'}>
{email.folder === 'SENT' ? (
<Send className="w-4 h-4" />
) : (
<Inbox className="w-4 h-4" />
)}
</div>
{/* Restore */}
<button
onClick={(e) => handleRestoreClick(e, email.id)}
className="flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-green-100 text-gray-400 hover:text-green-600"
title="Wiederherstellen"
>
<Undo2 className="w-4 h-4" />
</button>
{/* Permanent Delete */}
<button
onClick={(e) => handleDeleteClick(e, email.id)}
className="flex-shrink-0 mt-1 p-1 -ml-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600"
title="Endgültig löschen"
>
<Trash2 className="w-4 h-4" />
</button>
{/* Email Content */}
<div className="flex-1 min-w-0">
{/* From/To & Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm truncate text-gray-700">
{getDisplayName(email)}
</span>
<span className="text-xs text-gray-500 flex-shrink-0">
{formatDate(email.receivedAt)}
</span>
</div>
{/* Subject */}
<div className="text-sm truncate text-gray-600">
{email.subject || '(Kein Betreff)'}
</div>
{/* Deleted At */}
<div className="text-xs text-red-500 mt-1">
{formatDeletedAt(email.deletedAt)}
</div>
</div>
{/* Chevron */}
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0 mt-2" />
</div>
))}
{/* Bestätigungs-Modal */}
{actionConfirmId && actionType && (
<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">
{actionType === 'restore' ? 'E-Mail wiederherstellen?' : 'E-Mail endgültig löschen?'}
</h3>
<p className="text-gray-600 mb-4">
{actionType === 'restore'
? 'Die E-Mail wird wieder in den ursprünglichen Ordner verschoben.'
: 'Die E-Mail wird unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.'}
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={handleCancel}
disabled={restoreMutation.isPending || deleteMutation.isPending}
>
Abbrechen
</Button>
<Button
variant={actionType === 'restore' ? 'primary' : 'danger'}
onClick={handleConfirm}
disabled={restoreMutation.isPending || deleteMutation.isPending}
>
{restoreMutation.isPending || deleteMutation.isPending
? 'Wird ausgeführt...'
: actionType === 'restore'
? 'Wiederherstellen'
: 'Endgültig löschen'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { default as EmailList } from './EmailList';
export { default as EmailDetail } from './EmailDetail';
export { default as ComposeEmailModal } from './ComposeEmailModal';
export { default as AssignToContractModal } from './AssignToContractModal';
export { default as EmailClientTab } from './EmailClientTab';
export { default as ContractEmailsSection } from './ContractEmailsSection';
+2 -2
View File
@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
title?: string;
title?: ReactNode;
actions?: ReactNode;
}
@@ -12,7 +12,7 @@ export default function Card({ children, className = '', title, actions }: CardP
<div className={`bg-white rounded-lg shadow ${className}`}>
{(title || actions) && (
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
{title && <h3 className="text-lg font-medium text-gray-900">{title}</h3>}
{title && <div className="text-lg font-medium text-gray-900">{title}</div>}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}