From 97b4670643b5d55662c77f564a71eb84e87d9126 Mon Sep 17 00:00:00 2001 From: dufyfduck Date: Tue, 3 Feb 2026 23:58:00 +0100 Subject: [PATCH] save attachment from email in customer data and - or contracts --- README.md | 37 ++ backend/src/config/documentTargets.config.ts | 141 ++++++ .../src/controllers/cachedEmail.controller.ts | 467 ++++++++++++++++++ backend/src/routes/cachedEmail.routes.ts | 18 + backend/src/services/imapService.ts | 20 +- frontend/src/components/email/EmailDetail.tsx | 24 +- .../components/email/SaveAttachmentModal.tsx | 295 +++++++++++ frontend/src/services/api.ts | 46 ++ 8 files changed, 1037 insertions(+), 11 deletions(-) create mode 100644 backend/src/config/documentTargets.config.ts create mode 100644 frontend/src/components/email/SaveAttachmentModal.tsx diff --git a/README.md b/README.md index 37967c0a..a21aeee3 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,7 @@ model CachedEmail { |--------|--------------| | E-Mails lesen | `customers:read` | | E-Mails senden, markieren | `customers:update` | +| Anhänge in Dokumente speichern | `customers:update` | | Vertrag zuordnen | `contracts:update` | | Löschen, Papierkorb | `emails:delete` | @@ -502,6 +503,42 @@ model CachedEmail { | `TrashEmailList.tsx` | Papierkorb-Verwaltung | | `AssignToContractModal.tsx` | Vertragszuordnung | | `ContractEmailsSection.tsx` | E-Mails in Vertragsansicht | +| `SaveAttachmentModal.tsx` | Anhänge in Dokumentfelder speichern | + +### Anhänge als Dokumente speichern + +E-Mail-Anhänge können direkt in Dokumentfelder des CRM gespeichert werden. Über den blauen Speichern-Button (💾) neben jedem Anhang öffnet sich ein Modal mit allen verfügbaren Zielen. + +#### Verfügbare Ziele + +| Kategorie | Dokumentfelder | +|-----------|----------------| +| **Kunde** | Datenschutzerklärung | +| **Kunde (Gewerbe)** | + Gewerbeanmeldung, Handelsregisterauszug | +| **Ausweisdokumente** | Dokumentscan (pro Ausweis) | +| **Bankkarten** | Kartenscan (pro Karte) | +| **Vertrag** | Kündigungsschreiben, Kündigungsbestätigung, Kündigungsschreiben (Optionen), Kündigungsbestätigung (Optionen) | + +> **Hinweis:** Vertragsdokumente sind nur verfügbar, wenn die E-Mail einem Vertrag zugeordnet ist. + +#### Dynamische Konfiguration + +Die Dokumentziele werden zentral in `backend/src/config/documentTargets.config.ts` konfiguriert. Neue Dokumentfelder werden automatisch im Modal angezeigt, ohne Frontend-Änderungen. + +```typescript +// Beispiel: Neues Feld hinzufügen +{ + key: 'newDocument', + label: 'Neues Dokument', + field: 'newDocumentPath', // Prisma-Feld + condition: null, // oder 'BUSINESS' für Geschäftskunden + directory: 'new-documents' // Upload-Verzeichnis +} +``` + +#### Warnung bei Überschreiben + +Wenn bereits ein Dokument im Zielfeld vorhanden ist, wird eine Warnung angezeigt. Das vorhandene Dokument wird beim Speichern automatisch ersetzt und die alte Datei gelöscht. ### Vertragszuordnung aufheben (X-Button) diff --git a/backend/src/config/documentTargets.config.ts b/backend/src/config/documentTargets.config.ts new file mode 100644 index 00000000..f0d50724 --- /dev/null +++ b/backend/src/config/documentTargets.config.ts @@ -0,0 +1,141 @@ +/** + * Zentrale Konfiguration aller Dokumenten-Ziele für E-Mail-Anhänge. + * + * Bei neuen Dokumentfeldern einfach hier hinzufügen: + * 1. Neues Feld in der entsprechenden Kategorie anlegen + * 2. Upload-Route in upload.routes.ts hinzufügen (falls noch nicht vorhanden) + * 3. Das Frontend zeigt das neue Feld automatisch an + */ + +export interface DocumentTarget { + /** Eindeutiger Schlüssel für das Feld */ + key: string; + /** Anzeigename im UI */ + label: string; + /** Datenbankfeld-Name (z.B. 'privacyPolicyPath') */ + field: string; + /** Bedingung für Anzeige: 'BUSINESS' = nur Geschäftskunden, null = immer */ + condition?: 'BUSINESS' | null; + /** Upload-Verzeichnis (relativ zu /uploads/) */ + directory: string; +} + +export interface DocumentTargetConfig { + customer: DocumentTarget[]; + contract: DocumentTarget[]; + identityDocument: DocumentTarget[]; + bankCard: DocumentTarget[]; +} + +/** + * Alle verfügbaren Dokumenten-Ziele gruppiert nach Entity-Typ. + * + * WICHTIG: Bei neuen Feldern hier hinzufügen! + */ +export const documentTargets: DocumentTargetConfig = { + // Dokumente direkt am Kunden + customer: [ + { + key: 'privacyPolicy', + label: 'Datenschutzerklärung', + field: 'privacyPolicyPath', + condition: null, // Für alle Kunden + directory: 'privacy-policies', + }, + { + key: 'businessRegistration', + label: 'Gewerbeanmeldung', + field: 'businessRegistrationPath', + condition: 'BUSINESS', // Nur Geschäftskunden + directory: 'business-registrations', + }, + { + key: 'commercialRegister', + label: 'Handelsregisterauszug', + field: 'commercialRegisterPath', + condition: 'BUSINESS', // Nur Geschäftskunden + directory: 'commercial-registers', + }, + ], + + // Dokumente am Vertrag + contract: [ + { + key: 'cancellationLetter', + label: 'Kündigungsschreiben', + field: 'cancellationLetterPath', + directory: 'cancellation-letters', + }, + { + key: 'cancellationConfirmation', + label: 'Kündigungsbestätigung', + field: 'cancellationConfirmationPath', + directory: 'cancellation-confirmations', + }, + { + key: 'cancellationLetterOptions', + label: 'Kündigungsschreiben (Optionen)', + field: 'cancellationLetterOptionsPath', + directory: 'cancellation-letters-options', + }, + { + key: 'cancellationConfirmationOptions', + label: 'Kündigungsbestätigung (Optionen)', + field: 'cancellationConfirmationOptionsPath', + directory: 'cancellation-confirmations-options', + }, + ], + + // Dokumente an Ausweisen (pro Ausweis-Eintrag) + identityDocument: [ + { + key: 'document', + label: 'Ausweis-Scan', + field: 'documentPath', + directory: 'documents', + }, + ], + + // Dokumente an Bankkarten (pro Bankkarte) + bankCard: [ + { + key: 'document', + label: 'Bankkarten-Dokument', + field: 'documentPath', + directory: 'bank-cards', + }, + ], +}; + +/** + * Hilfsfunktion: Gibt alle Kunden-Dokumentziele zurück, + * gefiltert nach Kundentyp (PRIVATE/BUSINESS) + */ +export function getCustomerTargets(customerType: 'PRIVATE' | 'BUSINESS'): DocumentTarget[] { + return documentTargets.customer.filter(target => { + if (target.condition === null) return true; + if (target.condition === 'BUSINESS' && customerType === 'BUSINESS') return true; + return false; + }); +} + +/** + * Hilfsfunktion: Gibt alle Vertrags-Dokumentziele zurück + */ +export function getContractTargets(): DocumentTarget[] { + return documentTargets.contract; +} + +/** + * Hilfsfunktion: Gibt das Ausweis-Dokumentziel zurück + */ +export function getIdentityDocumentTargets(): DocumentTarget[] { + return documentTargets.identityDocument; +} + +/** + * Hilfsfunktion: Gibt das Bankkarten-Dokumentziel zurück + */ +export function getBankCardTargets(): DocumentTarget[] { + return documentTargets.bankCard; +} diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 52b9921f..f1819d35 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -8,6 +8,12 @@ import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imap import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js'; import { decrypt } from '../utils/encryption.js'; import { ApiResponse } from '../types/index.js'; +import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js'; +import { PrismaClient, DocumentType } from '@prisma/client'; +import path from 'path'; +import fs from 'fs'; + +const prisma = new PrismaClient(); // ==================== E-MAIL LIST ==================== @@ -803,3 +809,464 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise } as ApiResponse); } } + +// ==================== ATTACHMENT TARGETS ==================== + +// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen +export async function getAttachmentTargets(req: Request, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + + // E-Mail mit StressfreiEmail laden + const email = await cachedEmailService.getCachedEmailById(emailId); + if (!email) { + res.status(404).json({ + success: false, + error: 'E-Mail nicht gefunden', + } as ApiResponse); + return; + } + + // StressfreiEmail laden um an den Kunden zu kommen + const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ + where: { id: email.stressfreiEmailId }, + include: { + customer: { + include: { + identityDocuments: { + where: { isActive: true }, + select: { id: true, type: true, documentNumber: true, documentPath: true }, + }, + bankCards: { + where: { isActive: true }, + select: { id: true, iban: true, bankName: true, documentPath: true }, + }, + }, + }, + }, + }); + + if (!stressfreiEmail) { + res.status(404).json({ + success: false, + error: 'E-Mail-Konto nicht gefunden', + } as ApiResponse); + return; + } + + const customer = stressfreiEmail.customer; + const customerType = customer.type as 'PRIVATE' | 'BUSINESS'; + + // Ergebnis-Struktur + interface TargetSlot { + key: string; + label: string; + field: string; + hasDocument: boolean; + currentPath?: string; + } + + interface EntityWithSlots { + id: number; + label: string; + slots: TargetSlot[]; + } + + interface AttachmentTargetsResponse { + customer: { + id: number; + name: string; + type: string; + slots: TargetSlot[]; + }; + identityDocuments: EntityWithSlots[]; + bankCards: EntityWithSlots[]; + contract?: { + id: number; + contractNumber: string; + slots: TargetSlot[]; + }; + } + + // Kunden-Dokumentziele (gefiltert nach Kundentyp) + const customerTargets = getCustomerTargets(customerType); + const customerSlots: TargetSlot[] = customerTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(customer as any)[target.field], + currentPath: (customer as any)[target.field] || undefined, + })); + + // Ausweis-Dokumentziele + const identityTargets = getIdentityDocumentTargets(); + const identityDocuments: EntityWithSlots[] = customer.identityDocuments.map((doc: { id: number; type: DocumentType; documentNumber: string; documentPath: string | null }) => { + const docTypeLabels: Record = { + ID_CARD: 'Personalausweis', + PASSPORT: 'Reisepass', + DRIVERS_LICENSE: 'Führerschein', + OTHER: 'Sonstiges', + }; + return { + id: doc.id, + label: `${docTypeLabels[doc.type] || doc.type}: ${doc.documentNumber}`, + slots: identityTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(doc as Record)[target.field], + currentPath: ((doc as Record)[target.field] as string | undefined) || undefined, + })), + }; + }); + + // Bankkarten-Dokumentziele + const bankCardTargets = getBankCardTargets(); + const bankCards: EntityWithSlots[] = customer.bankCards.map((card: { id: number; iban: string; bankName: string | null; documentPath: string | null }) => ({ + id: card.id, + label: `${card.bankName || 'Bank'}: ${card.iban.slice(-4)}`, + slots: bankCardTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(card as Record)[target.field], + currentPath: ((card as Record)[target.field] as string | undefined) || undefined, + })), + })); + + // Basis-Antwort + const response: AttachmentTargetsResponse = { + customer: { + id: customer.id, + name: customer.companyName || `${customer.firstName} ${customer.lastName}`, + type: customerType, + slots: customerSlots, + }, + identityDocuments, + bankCards, + }; + + // Vertrag hinzufügen, falls E-Mail einem Vertrag zugeordnet ist + if (email.contractId) { + const contract = await prisma.contract.findUnique({ + where: { id: email.contractId }, + select: { + id: true, + contractNumber: true, + cancellationLetterPath: true, + cancellationConfirmationPath: true, + cancellationLetterOptionsPath: true, + cancellationConfirmationOptionsPath: true, + }, + }); + + if (contract) { + const contractTargets = getContractTargets(); + response.contract = { + id: contract.id, + contractNumber: contract.contractNumber, + slots: contractTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(contract as any)[target.field], + currentPath: (contract as any)[target.field] || undefined, + })), + }; + } + } + + res.json({ success: true, data: response } as ApiResponse); + } catch (error) { + console.error('getAttachmentTargets error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Laden der Dokumenten-Ziele', + } as ApiResponse); + } +} + +// E-Mail-Anhang in ein Dokumentenfeld speichern +export async function saveAttachmentTo(req: Request, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + const filename = decodeURIComponent(req.params.filename); + const { entityType, entityId, targetKey } = req.body; + + console.log('[saveAttachmentTo] Request:', { emailId, filename, entityType, entityId, targetKey }); + + // Validierung + if (!entityType || !targetKey) { + res.status(400).json({ + success: false, + error: 'entityType und targetKey sind erforderlich', + } as ApiResponse); + return; + } + + // E-Mail aus Cache laden + const email = await cachedEmailService.getCachedEmailById(emailId); + if (!email) { + res.status(404).json({ + success: false, + error: 'E-Mail nicht gefunden', + } as ApiResponse); + return; + } + + // Für gesendete E-Mails: Prüfen ob UID vorhanden (im IMAP Sent gespeichert) + if (email.folder === 'SENT' && email.uid === 0) { + res.status(400).json({ + success: false, + error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet', + } as ApiResponse); + return; + } + + // StressfreiEmail laden um Zugangsdaten zu bekommen + const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId); + if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) { + res.status(400).json({ + success: false, + error: 'Keine Mailbox-Zugangsdaten verfügbar', + } as ApiResponse); + return; + } + + // IMAP-Einstellungen laden + const settings = await getImapSmtpSettings(); + if (!settings) { + res.status(400).json({ + success: false, + error: 'Keine E-Mail-Provider-Einstellungen gefunden', + } as ApiResponse); + return; + } + + // Passwort entschlüsseln + const password = decrypt(stressfreiEmail.emailPasswordEncrypted); + + // IMAP-Credentials + const credentials: ImapCredentials = { + host: settings.imapServer, + port: settings.imapPort, + user: stressfreiEmail.email, + password, + encryption: settings.imapEncryption, + allowSelfSignedCerts: settings.allowSelfSignedCerts, + }; + + // Ordner basierend auf E-Mail-Typ bestimmen (INBOX oder Sent) + const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX'; + + // Anhang per IMAP abrufen + const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder); + + if (!attachment) { + res.status(404).json({ + success: false, + error: 'Anhang nicht gefunden', + } as ApiResponse); + return; + } + + // Ziel-Konfiguration finden + let targetConfig; + let targetDir: string; + let targetField: string; + + console.log('[saveAttachmentTo] Looking for target config:', { entityType, targetKey }); + + if (entityType === 'customer') { + targetConfig = documentTargets.customer.find(t => t.key === targetKey); + } else if (entityType === 'identityDocument') { + targetConfig = documentTargets.identityDocument.find(t => t.key === targetKey); + } else if (entityType === 'bankCard') { + targetConfig = documentTargets.bankCard.find(t => t.key === targetKey); + } else if (entityType === 'contract') { + targetConfig = documentTargets.contract.find(t => t.key === targetKey); + } + + console.log('[saveAttachmentTo] Found targetConfig:', targetConfig); + + if (!targetConfig) { + res.status(400).json({ + success: false, + error: `Unbekanntes Dokumentziel: ${entityType}/${targetKey}`, + } as ApiResponse); + return; + } + + targetDir = targetConfig.directory; + targetField = targetConfig.field; + + // Uploads-Verzeichnis erstellen + const uploadsDir = path.join(process.cwd(), 'uploads', targetDir); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + // Eindeutigen Dateinamen generieren + const ext = path.extname(filename) || '.pdf'; + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const newFilename = `${uniqueSuffix}${ext}`; + const filePath = path.join(uploadsDir, newFilename); + const relativePath = `/uploads/${targetDir}/${newFilename}`; + + // Datei speichern + fs.writeFileSync(filePath, attachment.content); + + // Alte Datei löschen und DB aktualisieren + if (entityType === 'customer') { + // Customer ID aus StressfreiEmail laden + console.log('[saveAttachmentTo] Looking up customer for stressfreiEmail.id:', stressfreiEmail.id); + const customer = await prisma.customer.findFirst({ + where: { + stressfreiEmails: { some: { id: stressfreiEmail.id } }, + }, + }); + console.log('[saveAttachmentTo] Found customer:', customer?.id); + + if (!customer) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Kunde nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (customer as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.customer.update({ + where: { id: customer.id }, + data: { [targetField]: relativePath }, + }); + } else if (entityType === 'identityDocument') { + if (!entityId) { + fs.unlinkSync(filePath); + res.status(400).json({ + success: false, + error: 'entityId ist für identityDocument erforderlich', + } as ApiResponse); + return; + } + + const doc = await prisma.identityDocument.findUnique({ where: { id: entityId } }); + if (!doc) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Ausweis nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (doc as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.identityDocument.update({ + where: { id: entityId }, + data: { [targetField]: relativePath }, + }); + } else if (entityType === 'bankCard') { + if (!entityId) { + fs.unlinkSync(filePath); + res.status(400).json({ + success: false, + error: 'entityId ist für bankCard erforderlich', + } as ApiResponse); + return; + } + + const card = await prisma.bankCard.findUnique({ where: { id: entityId } }); + if (!card) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Bankkarte nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (card as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.bankCard.update({ + where: { id: entityId }, + data: { [targetField]: relativePath }, + }); + } else if (entityType === 'contract') { + // Contract-ID kommt aus der E-Mail-Zuordnung + if (!email.contractId) { + fs.unlinkSync(filePath); + res.status(400).json({ + success: false, + error: 'E-Mail ist keinem Vertrag zugeordnet', + } as ApiResponse); + return; + } + + const contract = await prisma.contract.findUnique({ where: { id: email.contractId } }); + if (!contract) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Vertrag nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (contract as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.contract.update({ + where: { id: email.contractId }, + data: { [targetField]: relativePath }, + }); + } + + res.json({ + success: true, + data: { + path: relativePath, + filename: newFilename, + originalName: filename, + size: attachment.size, + }, + } as ApiResponse); + } catch (error) { + console.error('saveAttachmentTo error:', error); + // Detailliertere Fehlermeldung für Debugging + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + res.status(500).json({ + success: false, + error: `Fehler beim Speichern des Anhangs: ${errorMessage}`, + } as ApiResponse); + } +} diff --git a/backend/src/routes/cachedEmail.routes.ts b/backend/src/routes/cachedEmail.routes.ts index 715d2b13..49ad1a36 100644 --- a/backend/src/routes/cachedEmail.routes.ts +++ b/backend/src/routes/cachedEmail.routes.ts @@ -158,6 +158,24 @@ router.get( cachedEmailController.downloadAttachment ); +// Verfügbare Dokumenten-Ziele für Anhänge (zum Speichern) +// GET /api/emails/:id/attachment-targets +router.get( + '/emails/:id/attachment-targets', + authenticate, + requirePermission('customers:read'), + cachedEmailController.getAttachmentTargets +); + +// Anhang in Dokumentenfeld speichern +// POST /api/emails/:id/attachments/:filename/save-to { entityType, entityId?, targetKey } +router.post( + '/emails/:id/attachments/:filename/save-to', + authenticate, + requirePermission('customers:update'), + cachedEmailController.saveAttachmentTo +); + // ==================== VERTRAGSZUORDNUNG ==================== // E-Mail Vertrag zuordnen diff --git a/backend/src/services/imapService.ts b/backend/src/services/imapService.ts index f44cb30b..2c1f6a1f 100644 --- a/backend/src/services/imapService.ts +++ b/backend/src/services/imapService.ts @@ -163,12 +163,12 @@ export async function fetchEmails( return []; } - // E-Mails abrufen - for await (const message of client.fetch(limitedUids, { - uid: true, + // E-Mails abrufen - drittes Argument { uid: true } für UID FETCH + for await (const message of client.fetch(limitedUids.join(','), { + uid: true, // UID im Response inkludieren envelope: true, source: true, // Vollständige E-Mail für Parsing - })) { + }, { uid: true })) { try { // Source muss vorhanden sein if (!message.source) { @@ -389,10 +389,10 @@ export async function fetchAttachment( // E-Mail per UID abrufen let attachment: EmailAttachmentData | null = null; - for await (const message of client.fetch([uid], { - uid: true, + // Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird + for await (const message of client.fetch(uid.toString(), { source: true, - })) { + }, { uid: true })) { if (!message.source) continue; // E-Mail parsen @@ -555,10 +555,10 @@ export async function fetchAttachmentList( await client.connect(); await client.mailboxOpen(folder); - for await (const message of client.fetch([uid], { - uid: true, + // Drittes Argument { uid: true } für UID FETCH + for await (const message of client.fetch(uid.toString(), { source: true, - })) { + }, { uid: true })) { if (!message.source) continue; const parsed = await simpleParser(message.source); diff --git a/frontend/src/components/email/EmailDetail.tsx b/frontend/src/components/email/EmailDetail.tsx index a9475f11..5f417f70 100644 --- a/frontend/src/components/email/EmailDetail.tsx +++ b/frontend/src/components/email/EmailDetail.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; -import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2 } from 'lucide-react'; +import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save } from 'lucide-react'; import { CachedEmail, cachedEmailApi } from '../../services/api'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import Button from '../ui/Button'; import { Link } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import toast from 'react-hot-toast'; +import SaveAttachmentModal from './SaveAttachmentModal'; interface EmailDetailProps { email: CachedEmail; @@ -35,6 +36,7 @@ export default function EmailDetail({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false); + const [saveAttachmentFilename, setSaveAttachmentFilename] = useState(null); const queryClient = useQueryClient(); const { hasPermission } = useAuth(); @@ -327,6 +329,16 @@ export default function EmailDetail({ > + {/* Speichern-Button (nicht im Papierkorb) */} + {!isTrashView && ( + + )} ))} @@ -459,6 +471,16 @@ export default function EmailDetail({ )} + + {/* Anhang speichern Modal */} + {saveAttachmentFilename && ( + setSaveAttachmentFilename(null)} + emailId={email.id} + attachmentFilename={saveAttachmentFilename} + /> + )} ); } diff --git a/frontend/src/components/email/SaveAttachmentModal.tsx b/frontend/src/components/email/SaveAttachmentModal.tsx new file mode 100644 index 00000000..7a5100da --- /dev/null +++ b/frontend/src/components/email/SaveAttachmentModal.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react'; +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; +import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; + +interface SaveAttachmentModalProps { + isOpen: boolean; + onClose: () => void; + emailId: number; + attachmentFilename: string; + onSuccess?: () => void; +} + +type SelectedTarget = { + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract'; + entityId?: number; + targetKey: string; + hasDocument: boolean; + label: string; +}; + +export default function SaveAttachmentModal({ + isOpen, + onClose, + emailId, + attachmentFilename, + onSuccess, +}: SaveAttachmentModalProps) { + const [selectedTarget, setSelectedTarget] = useState(null); + const [expandedSections, setExpandedSections] = useState>(new Set(['customer'])); + const queryClient = useQueryClient(); + + // Ziele laden + const { data: targetsData, isLoading, error } = useQuery({ + queryKey: ['attachment-targets', emailId], + queryFn: () => cachedEmailApi.getAttachmentTargets(emailId), + enabled: isOpen, + }); + + const targets = targetsData?.data; + + const saveMutation = useMutation({ + mutationFn: () => { + if (!selectedTarget) throw new Error('Kein Ziel ausgewählt'); + return cachedEmailApi.saveAttachmentTo(emailId, attachmentFilename, { + entityType: selectedTarget.entityType, + entityId: selectedTarget.entityId, + targetKey: selectedTarget.targetKey, + }); + }, + onSuccess: () => { + toast.success('Anhang gespeichert'); + queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] }); + queryClient.invalidateQueries({ queryKey: ['customers'] }); + queryClient.invalidateQueries({ queryKey: ['contracts'] }); + + // Spezifische Ansichten aktualisieren (IDs als String, da URL-Params Strings sind) + if (targets?.customer?.id) { + queryClient.invalidateQueries({ queryKey: ['customer', targets.customer.id.toString()] }); + } + if (targets?.contract?.id) { + queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] }); + } + + onSuccess?.(); + handleClose(); + }, + onError: (error: Error) => { + toast.error(error.message || 'Fehler beim Speichern'); + }, + }); + + const handleClose = () => { + setSelectedTarget(null); + onClose(); + }; + + const toggleSection = (section: string) => { + const newExpanded = new Set(expandedSections); + if (newExpanded.has(section)) { + newExpanded.delete(section); + } else { + newExpanded.add(section); + } + setExpandedSections(newExpanded); + }; + + const handleSelectSlot = ( + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract', + slot: AttachmentTargetSlot, + entityId?: number, + parentLabel?: string + ) => { + setSelectedTarget({ + entityType, + entityId, + targetKey: slot.key, + hasDocument: slot.hasDocument, + label: parentLabel ? `${parentLabel} → ${slot.label}` : slot.label, + }); + }; + + const renderSlots = ( + slots: AttachmentTargetSlot[], + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract', + entityId?: number, + parentLabel?: string + ) => { + return slots.map((slot) => { + const isSelected = + selectedTarget?.entityType === entityType && + selectedTarget?.entityId === entityId && + selectedTarget?.targetKey === slot.key; + + return ( +
handleSelectSlot(entityType, slot, entityId, parentLabel)} + className={` + flex items-center gap-3 p-3 cursor-pointer transition-colors rounded-lg ml-4 + ${isSelected ? 'bg-blue-100 ring-2 ring-blue-500' : 'hover:bg-gray-100'} + `} + > +
+
+ {slot.label} + {slot.hasDocument && ( + + + Vorhanden + + )} +
+
+ {isSelected && } +
+ ); + }); + }; + + const renderEntityWithSlots = ( + entity: AttachmentEntityWithSlots, + entityType: 'identityDocument' | 'bankCard' + ) => { + return ( +
+
+ {entity.label} +
+ {renderSlots(entity.slots, entityType, entity.id, entity.label)} +
+ ); + }; + + const renderSection = ( + title: string, + sectionKey: string, + icon: React.ReactNode, + children: React.ReactNode, + isEmpty: boolean = false + ) => { + const isExpanded = expandedSections.has(sectionKey); + + return ( +
+ + {isExpanded && ( +
+ {isEmpty ? ( +

Keine Einträge vorhanden

+ ) : ( + children + )} +
+ )} +
+ ); + }; + + return ( + +
+ {/* Attachment Info */} +
+

+ Datei: {attachmentFilename} +

+
+ + {/* Loading */} + {isLoading && ( +
+
+
+ )} + + {/* Error */} + {error && ( +
+ Fehler beim Laden der Dokumentziele +
+ )} + + {/* Targets */} + {targets && ( +
+ {/* Kunde */} + {renderSection( + `Kunde: ${targets.customer.name}`, + 'customer', + , + renderSlots(targets.customer.slots, 'customer'), + targets.customer.slots.length === 0 + )} + + {/* Ausweise */} + {renderSection( + 'Ausweisdokumente', + 'identityDocuments', + , + targets.identityDocuments.map((doc) => + renderEntityWithSlots(doc, 'identityDocument') + ), + targets.identityDocuments.length === 0 + )} + + {/* Bankkarten */} + {renderSection( + 'Bankkarten', + 'bankCards', + , + targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')), + targets.bankCards.length === 0 + )} + + {/* Vertrag */} + {targets.contract && renderSection( + `Vertrag: ${targets.contract.contractNumber}`, + 'contract', + , + renderSlots(targets.contract.slots, 'contract'), + targets.contract.slots.length === 0 + )} + + {!targets.contract && ( +
+ + E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um + Vertragsdokumente als Ziel auswählen zu können. +
+ )} +
+ )} + + {/* Warning if replacing */} + {selectedTarget?.hasDocument && ( +
+ +
+ Achtung: An diesem Feld ist bereits ein Dokument hinterlegt. Das + vorhandene Dokument wird durch den neuen Anhang ersetzt. +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 615714a3..ca1deb16 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -293,6 +293,37 @@ export interface SendEmailParams { contractId?: number; // Vertrag dem die gesendete E-Mail zugeordnet wird } +// Anhang-Speicher-Ziele +export interface AttachmentTargetSlot { + key: string; + label: string; + field: string; + hasDocument: boolean; + currentPath?: string; +} + +export interface AttachmentEntityWithSlots { + id: number; + label: string; + slots: AttachmentTargetSlot[]; +} + +export interface AttachmentTargetsResponse { + customer: { + id: number; + name: string; + type: 'PRIVATE' | 'BUSINESS'; + slots: AttachmentTargetSlot[]; + }; + identityDocuments: AttachmentEntityWithSlots[]; + bankCards: AttachmentEntityWithSlots[]; + contract?: { + id: number; + contractNumber: string; + slots: AttachmentTargetSlot[]; + }; +} + export const stressfreiEmailApi = { getByCustomer: async (customerId: number, includeInactive = false) => { const res = await api.get>(`/customers/${customerId}/stressfrei-emails`, { params: { includeInactive } }); @@ -460,6 +491,21 @@ export const cachedEmailApi = { const res = await api.delete>(`/emails/${emailId}/permanent`); return res.data; }, + // ==================== ANHANG-SPEICHERUNG ==================== + // Verfügbare Dokumenten-Ziele für Anhänge abrufen + getAttachmentTargets: async (emailId: number) => { + const res = await api.get>(`/emails/${emailId}/attachment-targets`); + return res.data; + }, + // Anhang in Dokumentenfeld speichern + saveAttachmentTo: async (emailId: number, filename: string, params: { entityType: string; entityId?: number; targetKey: string }) => { + const encodedFilename = encodeURIComponent(filename); + const res = await api.post>( + `/emails/${emailId}/attachments/${encodedFilename}/save-to`, + params + ); + return res.data; + }, }; // Contracts