added backup and email client
This commit is contained in:
@@ -21,6 +21,7 @@ import ViewSettings from './pages/settings/ViewSettings';
|
||||
import PortalSettings from './pages/settings/PortalSettings';
|
||||
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
||||
import EmailProviders from './pages/settings/EmailProviders';
|
||||
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||
import UserList from './pages/users/UserList';
|
||||
import Settings from './pages/Settings';
|
||||
import DatabaseStructure from './pages/developer/DatabaseStructure';
|
||||
@@ -112,6 +113,7 @@ function App() {
|
||||
<Route path="settings/portal" element={<PortalSettings />} />
|
||||
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
||||
<Route path="settings/email-providers" element={<EmailProviders />} />
|
||||
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
||||
|
||||
{/* Redirect old users route */}
|
||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||
|
||||
@@ -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"><{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { AppSettingsProvider } from './context/AppSettingsContext';
|
||||
import App from './App';
|
||||
@@ -25,6 +26,28 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<AppSettingsProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</AppSettingsProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail } from 'lucide-react';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||
@@ -143,6 +143,23 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings/database-backup"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<Database className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
Datenbank & Zurücksetzen
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Backups erstellen, wiederherstellen oder auf Werkseinstellungen zurücksetzen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
|
||||
import { ContractEmailsSection } from '../../components/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -2326,6 +2327,14 @@ export default function ContractDetail() {
|
||||
isCustomerPortal={isCustomerPortal}
|
||||
/>
|
||||
|
||||
{/* Zugeordnete E-Mails */}
|
||||
{!isCustomerPortal && hasPermission('contracts:read') && c.customerId && (
|
||||
<ContractEmailsSection
|
||||
contractId={contractId}
|
||||
customerId={c.customerId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{c.notes && (
|
||||
<Card title="Notizen">
|
||||
<p className="whitespace-pre-wrap">{c.notes}</p>
|
||||
|
||||
@@ -142,6 +142,17 @@ export default function ContractForm() {
|
||||
}
|
||||
}, [isEdit]);
|
||||
|
||||
// Set preselected customer from URL params (for new contracts)
|
||||
useEffect(() => {
|
||||
if (!isEdit && preselectedCustomerId && customersData?.data) {
|
||||
// Only set if the customer exists in the list
|
||||
const customerExists = customersData.data.some(c => c.id.toString() === preselectedCustomerId);
|
||||
if (customerExists) {
|
||||
setValue('customerId', preselectedCustomerId);
|
||||
}
|
||||
}
|
||||
}, [isEdit, preselectedCustomerId, customersData, setValue]);
|
||||
|
||||
// Reset tariffId when providerId changes (but only after initial contract load)
|
||||
useEffect(() => {
|
||||
// Only reset tariff if:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, StressfreiEmail } from '../../services/api';
|
||||
import { EmailClientTab } from '../../components/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -132,6 +133,13 @@ export default function CustomerDetail() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'emails',
|
||||
label: 'E-Mail-Postfach',
|
||||
content: (
|
||||
<EmailClientTab customerId={customerId} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'contracts',
|
||||
label: 'Verträge',
|
||||
@@ -2793,9 +2801,21 @@ function StressfreiEmailModal({
|
||||
const [localPart, setLocalPart] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [provisionAtProvider, setProvisionAtProvider] = useState(false);
|
||||
const [createMailbox, setCreateMailbox] = useState(false);
|
||||
const [provisionError, setProvisionError] = useState<string | null>(null);
|
||||
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
|
||||
const [isProvisioning, setIsProvisioning] = useState(false);
|
||||
const [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
|
||||
const [mailboxEnabled, setMailboxEnabled] = useState(false);
|
||||
const [showCredentials, setShowCredentials] = useState(false);
|
||||
const [credentials, setCredentials] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
imap: { server: string; port: number; encryption: string } | null;
|
||||
smtp: { server: string; port: number; encryption: string } | null;
|
||||
} | null>(null);
|
||||
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!email;
|
||||
|
||||
@@ -2845,6 +2865,86 @@ function StressfreiEmailModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Mailbox nachträglich aktivieren
|
||||
const handleEnableMailbox = async () => {
|
||||
if (!email) return;
|
||||
setIsEnablingMailbox(true);
|
||||
setProvisionError(null);
|
||||
try {
|
||||
const result = await stressfreiEmailApi.enableMailbox(email.id);
|
||||
if (result.success) {
|
||||
setMailboxEnabled(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
|
||||
} else {
|
||||
setProvisionError(result.error || 'Mailbox-Aktivierung fehlgeschlagen');
|
||||
}
|
||||
} catch (error) {
|
||||
setProvisionError(error instanceof Error ? error.message : 'Fehler bei der Mailbox-Aktivierung');
|
||||
} finally {
|
||||
setIsEnablingMailbox(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mailbox-Status mit Provider synchronisieren
|
||||
const syncMailboxStatusFromProvider = async () => {
|
||||
if (!email) return;
|
||||
try {
|
||||
const result = await stressfreiEmailApi.syncMailboxStatus(email.id);
|
||||
if (result.success && result.data) {
|
||||
setMailboxEnabled(result.data.hasMailbox);
|
||||
if (result.data.wasUpdated) {
|
||||
// DB wurde aktualisiert, Query invalidieren
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Synchronisieren des Mailbox-Status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Mailbox-Zugangsdaten laden
|
||||
const loadCredentials = async () => {
|
||||
if (!email) return;
|
||||
setIsLoadingCredentials(true);
|
||||
try {
|
||||
const result = await stressfreiEmailApi.getMailboxCredentials(email.id);
|
||||
if (result.success && result.data) {
|
||||
setCredentials(result.data);
|
||||
setShowCredentials(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Zugangsdaten:', error);
|
||||
} finally {
|
||||
setIsLoadingCredentials(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Passwort zurücksetzen
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) return;
|
||||
if (!confirm('Neues Passwort generieren? Das alte Passwort wird ungültig.')) return;
|
||||
|
||||
setIsResettingPassword(true);
|
||||
try {
|
||||
const result = await stressfreiEmailApi.resetPassword(email.id);
|
||||
if (result.success && result.data) {
|
||||
// Credentials mit neuem Passwort aktualisieren
|
||||
if (credentials) {
|
||||
setCredentials({ ...credentials, password: result.data.password });
|
||||
}
|
||||
alert('Passwort wurde erfolgreich zurückgesetzt.');
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Zurücksetzen des Passworts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Zurücksetzen des Passworts:', error);
|
||||
alert(error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts');
|
||||
} finally {
|
||||
setIsResettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when modal opens or email changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -2853,40 +2953,45 @@ function StressfreiEmailModal({
|
||||
setLocalPart(emailLocalPart);
|
||||
setNotes(email.notes || '');
|
||||
setProviderStatus('idle');
|
||||
setMailboxEnabled(email.hasMailbox || false);
|
||||
// Status beim Provider prüfen wenn Provider vorhanden
|
||||
if (hasProvider) {
|
||||
checkProviderStatus(emailLocalPart);
|
||||
// Mailbox-Status synchronisieren
|
||||
syncMailboxStatusFromProvider();
|
||||
}
|
||||
} else {
|
||||
setLocalPart('');
|
||||
setNotes('');
|
||||
setProvisionAtProvider(false);
|
||||
setCreateMailbox(false);
|
||||
setProviderStatus('idle');
|
||||
setMailboxEnabled(false);
|
||||
}
|
||||
setProvisionError(null);
|
||||
// Zugangsdaten zurücksetzen
|
||||
setShowCredentials(false);
|
||||
setCredentials(null);
|
||||
}
|
||||
}, [isOpen, email, hasProvider]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { email: string; notes?: string; provision?: boolean }) => {
|
||||
// Wenn Provisionierung aktiviert, erst beim Provider anlegen
|
||||
if (data.provision && customerEmail) {
|
||||
const provisionResult = await emailProviderApi.provisionEmail(localPart, customerEmail);
|
||||
if (!provisionResult.data?.success) {
|
||||
throw new Error(provisionResult.data?.error || provisionResult.data?.message || 'Provisionierung fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
// Dann in der Datenbank anlegen
|
||||
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
|
||||
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
|
||||
return stressfreiEmailApi.create(customerId, {
|
||||
email: data.email,
|
||||
notes: data.notes,
|
||||
provisionAtProvider: data.provision,
|
||||
createMailbox: data.createMailbox,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
|
||||
setLocalPart('');
|
||||
setNotes('');
|
||||
setProvisionAtProvider(false);
|
||||
setCreateMailbox(false);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -2918,6 +3023,7 @@ function StressfreiEmailModal({
|
||||
email: fullEmail,
|
||||
notes: notes || undefined,
|
||||
provision: provisionAtProvider,
|
||||
createMailbox: provisionAtProvider && createMailbox,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2966,24 +3072,48 @@ function StressfreiEmailModal({
|
||||
{hasProvider && customerEmail && (
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
{!isEditing ? (
|
||||
// Erstellen-Modus: Checkbox
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provisionAtProvider}
|
||||
onChange={(e) => setProvisionAtProvider(e.target.checked)}
|
||||
className="mt-1 rounded border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Beim E-Mail-Provider anlegen
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Die E-Mail-Weiterleitung wird automatisch auf dem konfigurierten Server erstellt.
|
||||
Weiterleitungsziel: {customerEmail}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
// Erstellen-Modus: Checkboxen
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provisionAtProvider}
|
||||
onChange={(e) => {
|
||||
setProvisionAtProvider(e.target.checked);
|
||||
if (!e.target.checked) setCreateMailbox(false);
|
||||
}}
|
||||
className="mt-1 rounded border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Beim E-Mail-Provider anlegen
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Die E-Mail-Weiterleitung wird automatisch auf dem konfigurierten Server erstellt.
|
||||
Weiterleitungsziel: {customerEmail}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{provisionAtProvider && (
|
||||
<label className="flex items-start gap-2 cursor-pointer ml-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createMailbox}
|
||||
onChange={(e) => setCreateMailbox(e.target.checked)}
|
||||
className="mt-1 rounded border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Echte Mailbox erstellen (IMAP/SMTP-Zugang)
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Ermöglicht E-Mails direkt im CRM zu empfangen und zu versenden.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Bearbeiten-Modus: Status anzeigen
|
||||
<div className="space-y-2">
|
||||
@@ -3039,6 +3169,115 @@ function StressfreiEmailModal({
|
||||
Erneut prüfen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mailbox-Status anzeigen wenn Provider vorhanden */}
|
||||
{providerStatus === 'exists' && (
|
||||
<div className="pt-3 mt-3 border-t border-blue-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Mailbox (IMAP/SMTP)
|
||||
</span>
|
||||
{mailboxEnabled ? (
|
||||
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Mailbox aktiv
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-orange-600">Keine Mailbox</span>
|
||||
)}
|
||||
</div>
|
||||
{!mailboxEnabled && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Aktiviere eine echte Mailbox um E-Mails direkt im CRM zu empfangen und zu versenden.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleEnableMailbox}
|
||||
disabled={isEnablingMailbox}
|
||||
>
|
||||
{isEnablingMailbox ? 'Wird aktiviert...' : 'Mailbox aktivieren'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Zugangsdaten anzeigen wenn Mailbox aktiv */}
|
||||
{mailboxEnabled && (
|
||||
<div className="mt-3">
|
||||
{!showCredentials ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={loadCredentials}
|
||||
disabled={isLoadingCredentials}
|
||||
>
|
||||
{isLoadingCredentials ? (
|
||||
'Laden...'
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Zugangsdaten anzeigen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : credentials && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3 space-y-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">Zugangsdaten</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCredentials(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-500">E-Mail:</span>
|
||||
<p className="font-mono text-gray-900 break-all">{credentials.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Passwort:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-mono text-gray-900 break-all select-all flex-1">{credentials.password}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetPassword}
|
||||
disabled={isResettingPassword}
|
||||
className="text-blue-600 hover:text-blue-800 text-xs whitespace-nowrap disabled:opacity-50"
|
||||
title="Neues Kennwort generieren"
|
||||
>
|
||||
{isResettingPassword ? 'Generiere...' : 'Neu generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{credentials.imap && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-500">IMAP (Empfang)</span>
|
||||
<p className="font-mono text-xs text-gray-900">
|
||||
{credentials.imap.server}:{credentials.imap.port} ({credentials.imap.encryption})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{credentials.smtp && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-500">SMTP (Versand)</span>
|
||||
<p className="font-mono text-xs text-gray-900">
|
||||
{credentials.smtp.server}:{credentials.smtp.port} ({credentials.smtp.encryption})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText, Network } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ContractCategory } from '../../types';
|
||||
|
||||
@@ -17,6 +17,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
Flame: <Flame className="w-5 h-5" />,
|
||||
Wifi: <Wifi className="w-5 h-5" />,
|
||||
Cable: <Cable className="w-5 h-5" />,
|
||||
Network: <Network className="w-5 h-5" />,
|
||||
Smartphone: <Smartphone className="w-5 h-5" />,
|
||||
Tv: <Tv className="w-5 h-5" />,
|
||||
Car: <Car className="w-5 h-5" />,
|
||||
@@ -27,7 +28,8 @@ const availableIcons = [
|
||||
{ value: 'Zap', label: 'Blitz (Strom)' },
|
||||
{ value: 'Flame', label: 'Flamme (Gas)' },
|
||||
{ value: 'Wifi', label: 'WLAN (DSL)' },
|
||||
{ value: 'Cable', label: 'Kabel (Glasfaser)' },
|
||||
{ value: 'Cable', label: 'Kabel' },
|
||||
{ value: 'Network', label: 'Netzwerk (Glasfaser)' },
|
||||
{ value: 'Smartphone', label: 'Smartphone (Mobilfunk)' },
|
||||
{ value: 'Tv', label: 'TV' },
|
||||
{ value: 'Car', label: 'Auto (KFZ)' },
|
||||
@@ -88,7 +90,7 @@ export default function ContractCategoryList() {
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
|
||||
{hasPermission('platforms:create') && (
|
||||
{hasPermission('developer:access') && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Vertragstyp
|
||||
@@ -142,12 +144,12 @@ export default function ContractCategoryList() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{hasPermission('platforms:update') && (
|
||||
{hasPermission('developer:access') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('platforms:delete') && (
|
||||
{hasPermission('developer:access') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, RotateCcw } from 'lucide-react';
|
||||
import { backupApi, BackupInfo } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
export default function DatabaseBackup() {
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
|
||||
const [factoryResetConfirmText, setFactoryResetConfirmText] = useState('');
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { logout } = useAuth();
|
||||
|
||||
// Backups laden
|
||||
const { data: backupsData, isLoading } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: () => backupApi.list(),
|
||||
});
|
||||
|
||||
const backups = backupsData?.data || [];
|
||||
|
||||
// Backup erstellen
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => backupApi.create(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Backup wiederherstellen
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (name: string) => backupApi.restore(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setShowRestoreConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Backup löschen
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (name: string) => backupApi.delete(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setShowDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Backup hochladen
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => backupApi.upload(file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setUploadError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setUploadError(error.message || 'Upload fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
// Werkseinstellungen
|
||||
const factoryResetMutation = useMutation({
|
||||
mutationFn: () => backupApi.factoryReset(),
|
||||
onSuccess: () => {
|
||||
setShowFactoryResetConfirm(false);
|
||||
setFactoryResetConfirmText('');
|
||||
// Nach Factory Reset ausloggen
|
||||
logout();
|
||||
},
|
||||
});
|
||||
|
||||
// Datei-Upload Handler
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
setUploadError('Nur ZIP-Dateien sind erlaubt');
|
||||
return;
|
||||
}
|
||||
setUploadError(null);
|
||||
uploadMutation.mutate(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Download mit Auth-Token
|
||||
const handleDownload = async (name: string) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const url = backupApi.getDownloadUrl(name);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download fehlgeschlagen');
|
||||
}
|
||||
|
||||
// Blob erstellen und herunterladen
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = `opencrm-backup-${name}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
} catch (error: any) {
|
||||
console.error('Download error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Formatierung
|
||||
const formatDate = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
Datenbank & Zurücksetzen
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Backups erstellen, wiederherstellen oder auf Werkseinstellungen zurücksetzen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Upload Button */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".zip"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Hochladen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Backup hochladen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Create Button */}
|
||||
<Button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Wird erstellt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Neues Backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Error */}
|
||||
{uploadError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info-Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">Hinweise zur Datensicherung</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
|
||||
<li>Backups enthalten alle Datenbankdaten und hochgeladene Dokumente</li>
|
||||
<li>Erstellen Sie vor Datenbankmigrationen immer ein Backup</li>
|
||||
<li>Backups können als ZIP heruntergeladen und auf einem anderen System wiederhergestellt werden</li>
|
||||
<li>Bei der Wiederherstellung werden bestehende Daten mit dem Backup-Stand überschrieben</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Backup-Liste */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-700">Verfügbare Backups</h3>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<HardDrive className="w-12 h-12 mb-2 opacity-30" />
|
||||
<p>Keine Backups vorhanden</p>
|
||||
<p className="text-sm mt-1">Erstellen Sie Ihr erstes Backup</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{backups.map((backup: BackupInfo) => (
|
||||
<div key={backup.name} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-mono text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{backup.name}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDate(backup.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
{backup.totalRecords.toLocaleString('de-DE')} Datensätze
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
{formatSize(backup.sizeBytes)}
|
||||
</span>
|
||||
{backup.hasUploads && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Dokumente ({formatSize(backup.uploadSizeBytes)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Tabellen-Details (kollabiert) */}
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
|
||||
Tabellen anzeigen ({backup.tables.filter(t => t.count > 0).length} mit Daten)
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{backup.tables
|
||||
.filter(t => t.count > 0)
|
||||
.map(t => (
|
||||
<span
|
||||
key={t.table}
|
||||
className="text-xs bg-gray-100 px-2 py-0.5 rounded"
|
||||
>
|
||||
{t.table}: {t.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(backup.name)}
|
||||
title="Als ZIP herunterladen"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowRestoreConfirm(backup.name)}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
Wiederherstellen
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(backup.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wiederherstellungs-Bestätigung */}
|
||||
{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">
|
||||
Backup wiederherstellen?
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Möchten Sie das Backup <strong>{showRestoreConfirm}</strong> wirklich wiederherstellen?
|
||||
</p>
|
||||
<p className="text-amber-600 text-sm mb-4 bg-amber-50 p-3 rounded-lg">
|
||||
<strong>Achtung:</strong> Bestehende Daten und Dokumente werden mit dem Backup-Stand überschrieben.
|
||||
Dies kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowRestoreConfirm(null)}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => restoreMutation.mutate(showRestoreConfirm)}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
{restoreMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Wird wiederhergestellt...
|
||||
</>
|
||||
) : (
|
||||
'Ja, wiederherstellen'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lösch-Bestätigung */}
|
||||
{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">
|
||||
Backup löschen?
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Möchten Sie das Backup <strong>{showDeleteConfirm}</strong> wirklich löschen?
|
||||
Dies kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteMutation.mutate(showDeleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Wird gelöscht...' : 'Ja, löschen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Werkseinstellungen */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mt-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-red-800 mb-2">
|
||||
Werkseinstellungen
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
Setzt das System auf den Ausgangszustand zurück. <strong>Alle Daten werden unwiderruflich gelöscht</strong> -
|
||||
Kunden, Verträge, Benutzer, Dokumente und Einstellungen. Nur die hier gespeicherten Backups bleiben erhalten.
|
||||
</p>
|
||||
<ul className="text-sm text-red-700 mb-4 list-disc list-inside space-y-1">
|
||||
<li>Alle Kunden und Verträge werden gelöscht</li>
|
||||
<li>Alle Benutzer werden gelöscht</li>
|
||||
<li>Alle hochgeladenen Dokumente werden gelöscht</li>
|
||||
<li>Ein neuer Admin-Benutzer wird erstellt (admin@admin.com / admin)</li>
|
||||
<li><strong>Backups bleiben erhalten</strong> und können danach wiederhergestellt werden</li>
|
||||
</ul>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setShowFactoryResetConfirm(true)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Werkseinstellungen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Werkseinstellungen-Bestätigung */}
|
||||
{showFactoryResetConfirm && (
|
||||
<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-lg mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Wirklich auf Werkseinstellungen zurücksetzen?
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Diese Aktion löscht <strong>alle Daten unwiderruflich</strong>. Es gibt kein Zurück!
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Geben Sie zur Bestätigung <strong className="font-mono bg-gray-100 px-1">LÖSCHEN</strong> ein:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={factoryResetConfirmText}
|
||||
onChange={(e) => setFactoryResetConfirmText(e.target.value)}
|
||||
placeholder="LÖSCHEN"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg mb-4 focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowFactoryResetConfirm(false);
|
||||
setFactoryResetConfirmText('');
|
||||
}}
|
||||
disabled={factoryResetMutation.isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => factoryResetMutation.mutate()}
|
||||
disabled={factoryResetConfirmText !== 'LÖSCHEN' || factoryResetMutation.isPending}
|
||||
>
|
||||
{factoryResetMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Wird zurückgesetzt...
|
||||
</>
|
||||
) : (
|
||||
'Ja, alles löschen'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,15 @@ const PROVIDER_TYPES = [
|
||||
{ value: 'DIRECTADMIN', label: 'DirectAdmin' },
|
||||
];
|
||||
|
||||
// Verschlüsselungstyp
|
||||
type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
|
||||
|
||||
const ENCRYPTION_OPTIONS = [
|
||||
{ value: 'SSL', label: 'SSL/TLS', description: 'Verschlüsselung von Anfang an' },
|
||||
{ value: 'STARTTLS', label: 'STARTTLS', description: 'Startet unverschlüsselt, dann Upgrade' },
|
||||
{ value: 'NONE', label: 'Keine', description: 'Keine Verschlüsselung' },
|
||||
];
|
||||
|
||||
interface ProviderFormData {
|
||||
name: string;
|
||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
||||
@@ -23,6 +32,10 @@ interface ProviderFormData {
|
||||
password: string;
|
||||
domain: string;
|
||||
defaultForwardEmail: string;
|
||||
// Verschlüsselungs-Einstellungen
|
||||
imapEncryption: MailEncryption;
|
||||
smtpEncryption: MailEncryption;
|
||||
allowSelfSignedCerts: boolean;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
@@ -36,6 +49,9 @@ const emptyForm: ProviderFormData = {
|
||||
password: '',
|
||||
domain: 'stressfrei-wechseln.de',
|
||||
defaultForwardEmail: '',
|
||||
imapEncryption: 'SSL',
|
||||
smtpEncryption: 'SSL',
|
||||
allowSelfSignedCerts: false,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
};
|
||||
@@ -109,6 +125,9 @@ export default function EmailProviders() {
|
||||
password: '', // Passwort wird nicht geladen
|
||||
domain: config.domain,
|
||||
defaultForwardEmail: config.defaultForwardEmail || '',
|
||||
imapEncryption: config.imapEncryption ?? 'SSL',
|
||||
smtpEncryption: config.smtpEncryption ?? 'SSL',
|
||||
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
|
||||
isActive: config.isActive,
|
||||
isDefault: config.isDefault,
|
||||
});
|
||||
@@ -200,6 +219,9 @@ export default function EmailProviders() {
|
||||
username: formData.username,
|
||||
domain: formData.domain,
|
||||
defaultForwardEmail: formData.defaultForwardEmail,
|
||||
imapEncryption: formData.imapEncryption,
|
||||
smtpEncryption: formData.smtpEncryption,
|
||||
allowSelfSignedCerts: formData.allowSelfSignedCerts,
|
||||
isActive: formData.isActive,
|
||||
isDefault: formData.isDefault,
|
||||
};
|
||||
@@ -462,6 +484,69 @@ export default function EmailProviders() {
|
||||
Diese E-Mail-Adresse wird zusätzlich zur Kunden-E-Mail als Weiterleitungsziel hinzugefügt.
|
||||
</p>
|
||||
|
||||
{/* Verschlüsselungs-Einstellungen */}
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">E-Mail-Verbindungseinstellungen</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* IMAP Verschlüsselung */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
IMAP Verschlüsselung
|
||||
<span className="text-gray-400 font-normal ml-1">
|
||||
(Port {formData.imapEncryption === 'SSL' ? '993' : '143'})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.imapEncryption}
|
||||
onChange={(e) => setFormData({ ...formData, imapEncryption: e.target.value as MailEncryption })}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
{ENCRYPTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} - {opt.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* SMTP Verschlüsselung */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SMTP Verschlüsselung
|
||||
<span className="text-gray-400 font-normal ml-1">
|
||||
(Port {formData.smtpEncryption === 'SSL' ? '465' : formData.smtpEncryption === 'STARTTLS' ? '587' : '25'})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.smtpEncryption}
|
||||
onChange={(e) => setFormData({ ...formData, smtpEncryption: e.target.value as MailEncryption })}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
{ENCRYPTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} - {opt.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.allowSelfSignedCerts}
|
||||
onChange={(e) => setFormData({ ...formData, allowSelfSignedCerts: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Selbstsignierte Zertifikate erlauben</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Aktivieren Sie diese Option für Testumgebungen mit selbstsignierten SSL-Zertifikaten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -7,7 +7,7 @@ import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft, Info } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { User, Role } from '../../types';
|
||||
|
||||
@@ -89,6 +89,13 @@ export default function UserList() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Hinweis:</strong> Bei Änderungen an Rollen oder Berechtigungen wird der betroffene Benutzer automatisch ausgeloggt und muss sich erneut anmelden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
@@ -305,6 +312,7 @@ function UserModal({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
roleIds: formData.roleIds,
|
||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -381,20 +389,7 @@ function UserModal({
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
{/* Entwicklerzugriff direkt unter den Rollen */}
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -409,6 +404,26 @@ function UserModal({
|
||||
<span className="text-sm text-gray-500">(Datenbanktools)</span>
|
||||
</label>
|
||||
</div>
|
||||
{user && (
|
||||
<p className="mt-2 text-xs text-amber-600 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Bei Rollenänderung wird der Benutzer automatisch ausgeloggt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -216,16 +216,95 @@ export interface StressfreiEmail {
|
||||
platform?: string;
|
||||
notes?: string;
|
||||
isActive: boolean;
|
||||
hasMailbox: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Mailbox-Konto (für Dropdown-Auswahl)
|
||||
export interface MailboxAccount {
|
||||
id: number;
|
||||
email: string;
|
||||
notes?: string;
|
||||
hasMailbox: boolean;
|
||||
_count: {
|
||||
cachedEmails: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Gecachte E-Mail
|
||||
export interface CachedEmail {
|
||||
id: number;
|
||||
stressfreiEmailId: number;
|
||||
folder: 'INBOX' | 'SENT';
|
||||
messageId: string;
|
||||
uid: number;
|
||||
subject?: string;
|
||||
fromAddress: string;
|
||||
fromName?: string;
|
||||
toAddresses: string; // JSON Array
|
||||
ccAddresses?: string; // JSON Array
|
||||
receivedAt: string;
|
||||
textBody?: string;
|
||||
htmlBody?: string;
|
||||
hasAttachments: boolean;
|
||||
attachmentNames?: string; // JSON Array
|
||||
contractId?: number;
|
||||
assignedAt?: string;
|
||||
assignedBy?: number;
|
||||
isAutoAssigned?: boolean; // true = automatisch beim Senden aus Vertrag zugeordnet
|
||||
isRead: boolean;
|
||||
isStarred: boolean;
|
||||
isDeleted: boolean;
|
||||
deletedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stressfreiEmail?: {
|
||||
id: number;
|
||||
email: string;
|
||||
customerId: number;
|
||||
};
|
||||
contract?: {
|
||||
id: number;
|
||||
contractNumber: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
newEmails: number;
|
||||
totalEmails: number;
|
||||
}
|
||||
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: string; // Base64-kodierter Inhalt
|
||||
contentType?: string; // MIME-Type (z.B. 'application/pdf')
|
||||
}
|
||||
|
||||
export interface SendEmailParams {
|
||||
to: string | string[];
|
||||
cc?: string | string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
attachments?: EmailAttachment[];
|
||||
contractId?: number; // Vertrag dem die gesendete E-Mail zugeordnet wird
|
||||
}
|
||||
|
||||
export const stressfreiEmailApi = {
|
||||
getByCustomer: async (customerId: number, includeInactive = false) => {
|
||||
const res = await api.get<ApiResponse<StressfreiEmail[]>>(`/customers/${customerId}/stressfrei-emails`, { params: { includeInactive } });
|
||||
return res.data;
|
||||
},
|
||||
create: async (customerId: number, data: { email: string; platform?: string; notes?: string }) => {
|
||||
create: async (customerId: number, data: {
|
||||
email: string;
|
||||
platform?: string;
|
||||
notes?: string;
|
||||
provisionAtProvider?: boolean;
|
||||
createMailbox?: boolean;
|
||||
}) => {
|
||||
const res = await api.post<ApiResponse<StressfreiEmail>>(`/customers/${customerId}/stressfrei-emails`, data);
|
||||
return res.data;
|
||||
},
|
||||
@@ -237,6 +316,150 @@ export const stressfreiEmailApi = {
|
||||
const res = await api.delete<ApiResponse<void>>(`/stressfrei-emails/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
// Mailbox nachträglich aktivieren
|
||||
enableMailbox: async (id: number) => {
|
||||
const res = await api.post<ApiResponse<null>>(`/stressfrei-emails/${id}/enable-mailbox`);
|
||||
return res.data;
|
||||
},
|
||||
// Mailbox-Status mit Provider synchronisieren
|
||||
syncMailboxStatus: async (id: number) => {
|
||||
const res = await api.post<ApiResponse<{ hasMailbox: boolean; wasUpdated: boolean }>>(`/stressfrei-emails/${id}/sync-mailbox-status`);
|
||||
return res.data;
|
||||
},
|
||||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
||||
getMailboxCredentials: async (id: number) => {
|
||||
const res = await api.get<ApiResponse<{
|
||||
email: string;
|
||||
password: string;
|
||||
imap: { server: string; port: number; encryption: string } | null;
|
||||
smtp: { server: string; port: number; encryption: string } | null;
|
||||
}>>(`/stressfrei-emails/${id}/credentials`);
|
||||
return res.data;
|
||||
},
|
||||
// Passwort zurücksetzen (generiert neues Passwort beim Provider)
|
||||
resetPassword: async (id: number) => {
|
||||
const res = await api.post<ApiResponse<{ password: string }>>(`/stressfrei-emails/${id}/reset-password`);
|
||||
return res.data;
|
||||
},
|
||||
// E-Mails synchronisieren
|
||||
syncEmails: async (id: number, fullSync = false) => {
|
||||
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail senden
|
||||
sendEmail: async (id: number, params: SendEmailParams) => {
|
||||
const res = await api.post<ApiResponse<{ messageId: string }>>(`/stressfrei-emails/${id}/send`, params);
|
||||
return res.data;
|
||||
},
|
||||
// Ordner-Anzahlen abrufen (total und ungelesen pro Ordner)
|
||||
getFolderCounts: async (id: number) => {
|
||||
const res = await api.get<ApiResponse<{
|
||||
inbox: number;
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
trash: number;
|
||||
trashUnread: number;
|
||||
}>>(`/stressfrei-emails/${id}/folder-counts`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Cached Email API (E-Mail-Client)
|
||||
export const cachedEmailApi = {
|
||||
// E-Mails für Kunden abrufen
|
||||
getForCustomer: async (customerId: number, options?: { accountId?: number; folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
||||
return res.data;
|
||||
},
|
||||
// E-Mails für Vertrag abrufen
|
||||
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
|
||||
return res.data;
|
||||
},
|
||||
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
|
||||
getContractFolderCounts: async (contractId: number) => {
|
||||
const res = await api.get<ApiResponse<{
|
||||
inbox: number;
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
}>>(`/contracts/${contractId}/emails/folder-counts`);
|
||||
return res.data;
|
||||
},
|
||||
// Mailbox-Konten eines Kunden abrufen
|
||||
getMailboxAccounts: async (customerId: number) => {
|
||||
const res = await api.get<ApiResponse<MailboxAccount[]>>(`/customers/${customerId}/mailbox-accounts`);
|
||||
return res.data;
|
||||
},
|
||||
// Einzelne E-Mail abrufen (mit Body)
|
||||
getById: async (id: number) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail>>(`/emails/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail-Thread abrufen
|
||||
getThread: async (id: number) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/emails/${id}/thread`);
|
||||
return res.data;
|
||||
},
|
||||
// Als gelesen/ungelesen markieren
|
||||
markAsRead: async (id: number, isRead: boolean) => {
|
||||
const res = await api.patch<ApiResponse<void>>(`/emails/${id}/read`, { isRead });
|
||||
return res.data;
|
||||
},
|
||||
// Stern umschalten
|
||||
toggleStar: async (id: number) => {
|
||||
const res = await api.post<ApiResponse<{ isStarred: boolean }>>(`/emails/${id}/star`);
|
||||
return res.data;
|
||||
},
|
||||
// Vertrag zuordnen
|
||||
assignToContract: async (emailId: number, contractId: number) => {
|
||||
const res = await api.post<ApiResponse<CachedEmail>>(`/emails/${emailId}/assign`, { contractId });
|
||||
return res.data;
|
||||
},
|
||||
// Zuordnung aufheben
|
||||
unassignFromContract: async (emailId: number) => {
|
||||
const res = await api.delete<ApiResponse<CachedEmail>>(`/emails/${emailId}/assign`);
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail löschen (nur Admin)
|
||||
delete: async (emailId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/emails/${emailId}`);
|
||||
return res.data;
|
||||
},
|
||||
// Anhang-URL (view=true für inline anzeigen, sonst download)
|
||||
getAttachmentUrl: (emailId: number, filename: string, view?: boolean) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const encodedFilename = encodeURIComponent(filename);
|
||||
const viewParam = view ? '&view=true' : '';
|
||||
return `${api.defaults.baseURL}/emails/${emailId}/attachments/${encodedFilename}?token=${token}${viewParam}`;
|
||||
},
|
||||
// Ungelesene E-Mails zählen
|
||||
getUnreadCount: async (params: { customerId?: number; contractId?: number }) => {
|
||||
const res = await api.get<ApiResponse<{ count: number }>>('/emails/unread-count', { params });
|
||||
return res.data;
|
||||
},
|
||||
// ==================== PAPIERKORB ====================
|
||||
// Papierkorb-E-Mails für Kunden abrufen
|
||||
getTrash: async (customerId: number) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
|
||||
return res.data;
|
||||
},
|
||||
// Papierkorb-Anzahl für Kunden
|
||||
getTrashCount: async (customerId: number) => {
|
||||
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail aus Papierkorb wiederherstellen
|
||||
restore: async (emailId: number) => {
|
||||
const res = await api.post<ApiResponse<void>>(`/emails/${emailId}/restore`);
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail endgültig löschen (aus Papierkorb)
|
||||
permanentDelete: async (emailId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/emails/${emailId}/permanent`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Contracts
|
||||
@@ -378,6 +601,51 @@ export const appSettingsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Backup & Restore
|
||||
export interface BackupInfo {
|
||||
name: string;
|
||||
timestamp: string;
|
||||
totalRecords: number;
|
||||
tables: { table: string; count: number }[];
|
||||
sizeBytes: number;
|
||||
hasUploads: boolean;
|
||||
uploadSizeBytes: number;
|
||||
}
|
||||
|
||||
export const backupApi = {
|
||||
list: async () => {
|
||||
const res = await api.get<ApiResponse<BackupInfo[]>>('/settings/backups');
|
||||
return res.data;
|
||||
},
|
||||
create: async () => {
|
||||
const res = await api.post<ApiResponse<{ backupName: string }>>('/settings/backup');
|
||||
return res.data;
|
||||
},
|
||||
restore: async (name: string) => {
|
||||
const res = await api.post<ApiResponse<{ restoredRecords: number; restoredFiles: number }>>(`/settings/backup/${name}/restore`);
|
||||
return res.data;
|
||||
},
|
||||
delete: async (name: string) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/settings/backup/${name}`);
|
||||
return res.data;
|
||||
},
|
||||
getDownloadUrl: (name: string) => {
|
||||
return `/api/settings/backup/${name}/download`;
|
||||
},
|
||||
upload: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
const res = await api.post<ApiResponse<{ backupName: string }>>('/settings/backup/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
factoryReset: async () => {
|
||||
const res = await api.post<ApiResponse<void>>('/settings/factory-reset');
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Platforms
|
||||
export const platformApi = {
|
||||
getAll: async (includeInactive = false) => {
|
||||
@@ -645,7 +913,7 @@ export const userApi = {
|
||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number }) => {
|
||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean }) => {
|
||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||
return res.data;
|
||||
},
|
||||
@@ -698,6 +966,15 @@ export interface EmailProviderConfig {
|
||||
passwordEncrypted?: string;
|
||||
domain: string;
|
||||
defaultForwardEmail?: string;
|
||||
// IMAP/SMTP-Server (für E-Mail-Client)
|
||||
imapServer?: string;
|
||||
imapPort?: number;
|
||||
smtpServer?: string;
|
||||
smtpPort?: number;
|
||||
// Verschlüsselungs-Einstellungen
|
||||
imapEncryption?: 'SSL' | 'STARTTLS' | 'NONE';
|
||||
smtpEncryption?: 'SSL' | 'STARTTLS' | 'NONE';
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
|
||||
@@ -30,8 +30,10 @@ export interface StressfreiEmail {
|
||||
id: number;
|
||||
customerId: number;
|
||||
email: string;
|
||||
platform?: string;
|
||||
notes?: string;
|
||||
isActive: boolean;
|
||||
hasMailbox: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user