diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index e217f7d2..22612d59 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -1579,7 +1579,7 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + const filename = decodeURIComponent(req.params.filename); + const { documentType, notes } = req.body; + + if (!documentType || typeof documentType !== 'string') { + res.status(400).json({ + success: false, + error: 'documentType ist erforderlich', + } as ApiResponse); + return; + } + + const email = await cachedEmailService.getCachedEmailById(emailId); + if (!email) { + res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse); + return; + } + + if (!email.contractId) { + 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 }, + select: { id: true, contractNumber: true, customerId: true }, + }); + + if (!contract) { + res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse); + return; + } + + // Für gesendete E-Mails: Prüfen ob UID vorhanden + 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 für IMAP-Zugangsdaten + 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; + } + + const settings = await getImapSmtpSettings(); + if (!settings) { + res.status(400).json({ + success: false, + error: 'Keine E-Mail-Provider-Einstellungen gefunden', + } as ApiResponse); + return; + } + + const password = decrypt(stressfreiEmail.emailPasswordEncrypted); + + const credentials: ImapCredentials = { + host: settings.imapServer, + port: settings.imapPort, + user: stressfreiEmail.email, + password, + encryption: settings.imapEncryption, + allowSelfSignedCerts: settings.allowSelfSignedCerts, + }; + + const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX'; + + const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder); + if (!attachment) { + res.status(404).json({ + success: false, + error: 'Anhang nicht gefunden oder nicht mehr verfügbar', + } as ApiResponse); + return; + } + + // Uploads-Verzeichnis + const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents'); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + const ext = path.extname(filename) || '.pdf'; + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const newFilename = `${safeType}-${uniqueSuffix}${ext}`; + const filePath = path.join(uploadsDir, newFilename); + const relativePath = `/uploads/contract-documents/${newFilename}`; + + fs.writeFileSync(filePath, attachment.content); + + const doc = await prisma.contractDocument.create({ + data: { + contractId: contract.id, + documentType, + documentPath: relativePath, + originalName: filename, + notes: notes || null, + uploadedBy: (req as any).user?.email || 'email-import', + }, + }); + + res.json({ success: true, data: doc } as ApiResponse); + } catch (error) { + console.error('saveAttachmentAsContractDocument error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + res.status(500).json({ + success: false, + error: `Fehler beim Speichern: ${errorMessage}`, + } as ApiResponse); + } +} diff --git a/backend/src/routes/cachedEmail.routes.ts b/backend/src/routes/cachedEmail.routes.ts index 6ee51ed8..bc3d9f2d 100644 --- a/backend/src/routes/cachedEmail.routes.ts +++ b/backend/src/routes/cachedEmail.routes.ts @@ -203,6 +203,15 @@ router.post( cachedEmailController.saveAttachmentAsInvoice ); +// Anhang als Vertragsdokument speichern +// POST /api/emails/:id/attachments/:filename/save-as-contract-document { documentType, notes? } +router.post( + '/emails/:id/attachments/:filename/save-as-contract-document', + authenticate, + requirePermission('contracts:update'), + cachedEmailController.saveAttachmentAsContractDocument +); + // ==================== VERTRAGSZUORDNUNG ==================== // E-Mail Vertrag zuordnen diff --git a/backend/todo.md b/backend/todo.md index 1bbbe7e9..bec27246 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -10,11 +10,6 @@ ### Security System testen -### Email → Vertragsdokumente -Wenn eine Email einem Vertrag zugeordnet ist: -- Anhänge auch in Vertragsdokumente speichern -- Rechnungen wie Kündigungsdokumente behandeln - ### Factory-Defaults: Export + Import von Lieferanten & Formularvorlagen **Ziel:** Einmal gepflegte Stammdaten (Anbieter, Tarife, Kündigungsfristen, Laufzeiten, PDF-Auftragsvorlagen) sollen sich exportieren und in andere Installationen oder @@ -57,6 +52,14 @@ als Factory-Default beim Initialisieren wieder einspielen lassen. ## ✅ Erledigt +- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen** + - Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi: + 1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) – wie bisher + 2. **Als Vertragsdokument** – neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen + 3. **Als Rechnung** – jetzt für **alle** Vertragstypen (vorher nur Strom/Gas) + - Gleiches gilt für das Speichern der gesamten Email als PDF-Rechnung + - Neuer Backend-Endpoint `saveAttachmentAsContractDocument` für die flexible ContractDocument-Tabelle + - [x] **Geburtstag-Management-Modal in Kundenstammdaten** - Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal - **Gruß zurücksetzen:** setzt `lastBirthdayGreetingYear` auf null zurück (fürs Debugging + Fallback) diff --git a/frontend/src/components/email/SaveAttachmentModal.tsx b/frontend/src/components/email/SaveAttachmentModal.tsx index b46d025f..ee1c8760 100644 --- a/frontend/src/components/email/SaveAttachmentModal.tsx +++ b/frontend/src/components/email/SaveAttachmentModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react'; +import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt, FolderOpen } from 'lucide-react'; import Modal from '../ui/Modal'; import Button from '../ui/Button'; import Input from '../ui/Input'; @@ -9,6 +9,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import type { InvoiceType } from '../../types'; +const CONTRACT_DOCUMENT_TYPES = [ + 'Auftragsformular', + 'Auftragsbestätigung', + 'Lieferbestätigung', + 'Vertragsunterlagen', + 'Vollmacht', + 'Widerrufsbelehrung', + 'Preisblatt', + 'Sonstiges', +]; + interface SaveAttachmentModalProps { isOpen: boolean; onClose: () => void; @@ -25,7 +36,7 @@ type SelectedTarget = { label: string; }; -type SaveMode = 'document' | 'invoice'; +type SaveMode = 'document' | 'invoice' | 'contractDocument'; export default function SaveAttachmentModal({ isOpen, @@ -42,6 +53,10 @@ export default function SaveAttachmentModal({ invoiceType: 'INTERIM' as InvoiceType, notes: '', }); + const [contractDocumentData, setContractDocumentData] = useState({ + documentType: CONTRACT_DOCUMENT_TYPES[0], + notes: '', + }); const queryClient = useQueryClient(); // Ziele laden @@ -53,8 +68,8 @@ export default function SaveAttachmentModal({ const targets = targetsData?.data; - // Prüfen ob es ein Energievertrag ist - const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS'; + // Vertrag zugeordnet? → dann Rechnung + Vertragsdokument möglich + const hasContract = !!targets?.contract; const saveMutation = useMutation({ mutationFn: () => { @@ -113,6 +128,31 @@ export default function SaveAttachmentModal({ }, }); + const saveContractDocumentMutation = useMutation({ + mutationFn: () => { + return cachedEmailApi.saveAttachmentAsContractDocument(emailId, attachmentFilename, { + documentType: contractDocumentData.documentType, + notes: contractDocumentData.notes || undefined, + }); + }, + onSuccess: () => { + toast.success('Anhang als Vertragsdokument gespeichert'); + queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] }); + queryClient.invalidateQueries({ queryKey: ['contracts'] }); + + if (targets?.contract?.id) { + queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] }); + queryClient.invalidateQueries({ queryKey: ['contract-documents', targets.contract.id] }); + } + + onSuccess?.(); + handleClose(); + }, + onError: (error: Error) => { + toast.error(error.message || 'Fehler beim Speichern des Vertragsdokuments'); + }, + }); + const handleClose = () => { setSelectedTarget(null); setSaveMode('document'); @@ -121,6 +161,10 @@ export default function SaveAttachmentModal({ invoiceType: 'INTERIM', notes: '', }); + setContractDocumentData({ + documentType: CONTRACT_DOCUMENT_TYPES[0], + notes: '', + }); onClose(); }; @@ -263,12 +307,12 @@ export default function SaveAttachmentModal({ {targets && ( <> - {/* Mode Toggle für Energieverträge */} - {isEnergyContract && ( + {/* Mode Toggle (nur wenn ein Vertrag zugeordnet ist) */} + {hasContract && (
+ - {saveMode === 'document' ? ( + {saveMode === 'document' && ( - ) : ( + )} + {saveMode === 'invoice' && ( )} + {saveMode === 'contractDocument' && ( + + )}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e8018a1c..b29c648b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -585,6 +585,18 @@ export const cachedEmailApi = { ); return res.data; }, + saveAttachmentAsContractDocument: async ( + emailId: number, + filename: string, + params: { documentType: string; notes?: string }, + ) => { + const encodedFilename = encodeURIComponent(filename); + const res = await api.post>( + `/emails/${emailId}/attachments/${encodedFilename}/save-as-contract-document`, + params, + ); + return res.data; + }, }; // Contracts - Vertragsbaum für Kundenansicht