From 0f4ffe3c3244ba4da4c55c54b00fa9811dcdec76 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 1 Jun 2026 19:15:23 +0200 Subject: [PATCH] =?UTF-8?q?E-Mail=20als=20PDF=20speichern:=20Tab=20"Vertra?= =?UTF-8?q?gsdokument"=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher hatte das "E-Mail als PDF speichern"-Modal nur die Tabs "Als Dokument" + "Als Rechnung" (nur Energieverträge). Wenn die E-Mail einem Vertrag zugeordnet ist, fehlte die Möglichkeit, sie direkt als Vertragsdokument (Auftragsformular, Lieferbestätigung etc.) zu hinterlegen – analog zum Anhang-Modal. Backend: neuer Endpoint POST /api/emails/:id/save-as-contract-document { documentType, notes?, deliveryDate? } – generiert das Mail-PDF, speichert es unter /uploads/contract-documents und legt einen ContractDocument-Eintrag an. Bei documentType "Lieferbestätigung" wird der bestehende maybeActivateOnDeliveryConfirmation-Workflow getriggert (DRAFT → ACTIVE, startDate-Übernahme). Frontend: SaveEmailAsPdfModal bekommt den dritten Tab parallel zu SaveAttachmentModal. Tab erscheint, sobald die E-Mail einem Vertrag zugeordnet ist (auch bei Nicht-Energieverträgen); Tab "Als Rechnung" bleibt auf Energieverträge beschränkt. Dokumenttyp-Dropdown und Notizen-Feld werden aus dem Anhang-Modal übernommen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/cachedEmail.controller.ts | 87 +++++++++++ backend/src/routes/cachedEmail.routes.ts | 9 ++ .../components/email/SaveEmailAsPdfModal.tsx | 143 ++++++++++++++++-- frontend/src/services/api.ts | 12 ++ 4 files changed, 236 insertions(+), 15 deletions(-) diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 7d7f30ed..6b2f681d 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -1814,6 +1814,93 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi } } +// ==================== SAVE EMAIL AS CONTRACT DOCUMENT ==================== + +// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen. +// Parallel zu saveAttachmentAsContractDocument (Anhang-Variante) – damit +// auch reine Mail-Bestätigungen ohne Anhang als Auftragsformular/ +// Lieferbestätigung etc. an einem Vertrag landen können. +export async function saveEmailAsContractDocument(req: AuthRequest, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + if (!(await canAccessCachedEmail(req, res, emailId))) return; + 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; + } + if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return; + + // Empfänger-Adressen parsen + let toAddresses: string[] = []; + let ccAddresses: string[] = []; + try { toAddresses = JSON.parse(email.toAddresses); } catch { toAddresses = [email.toAddresses]; } + try { if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses); } catch { /* ignore */ } + + const pdfBuffer = await generateEmailPdf({ + from: email.fromAddress, + to: toAddresses.join(', '), + cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined, + subject: email.subject || '(Kein Betreff)', + date: email.receivedAt, + bodyText: email.textBody || undefined, + bodyHtml: email.htmlBody || undefined, + }); + + const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents'); + if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); + + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`; + const filePath = path.join(uploadsDir, newFilename); + const relativePath = `/uploads/contract-documents/${newFilename}`; + + fs.writeFileSync(filePath, pdfBuffer); + + const doc = await prisma.contractDocument.create({ + data: { + contractId: contract.id, + documentType, + documentPath: relativePath, + originalName: `${email.subject || 'email'}.pdf`, + notes: notes || null, + uploadedBy: (req as any).user?.email || 'email-import', + }, + }); + + // Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer + const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null; + await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate); + + res.json({ success: true, data: doc } as ApiResponse); + } catch (error) { + console.error('saveEmailAsContractDocument error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + res.status(500).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse); + } +} + // ==================== SAVE ATTACHMENT AS INVOICE ==================== // E-Mail-Anhang als Rechnung speichern diff --git a/backend/src/routes/cachedEmail.routes.ts b/backend/src/routes/cachedEmail.routes.ts index 6d83098d..ac8ec8b4 100644 --- a/backend/src/routes/cachedEmail.routes.ts +++ b/backend/src/routes/cachedEmail.routes.ts @@ -194,6 +194,15 @@ router.post( cachedEmailController.saveEmailAsInvoice ); +// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen +// POST /api/emails/:id/save-as-contract-document { documentType, notes?, deliveryDate? } +router.post( + '/emails/:id/save-as-contract-document', + authenticate, + requirePermission('contracts:update'), + cachedEmailController.saveEmailAsContractDocument +); + // Anhang als Rechnung speichern // POST /api/emails/:id/attachments/:filename/save-as-invoice { invoiceDate, invoiceType, notes? } router.post( diff --git a/frontend/src/components/email/SaveEmailAsPdfModal.tsx b/frontend/src/components/email/SaveEmailAsPdfModal.tsx index 15af6991..2e7a6d2a 100644 --- a/frontend/src/components/email/SaveEmailAsPdfModal.tsx +++ b/frontend/src/components/email/SaveEmailAsPdfModal.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 SaveEmailAsPdfModalProps { isOpen: boolean; onClose: () => void; @@ -24,7 +35,7 @@ type SelectedTarget = { label: string; }; -type SaveMode = 'document' | 'invoice'; +type SaveMode = 'document' | 'invoice' | 'contractDocument'; export default function SaveEmailAsPdfModal({ isOpen, @@ -40,6 +51,11 @@ export default function SaveEmailAsPdfModal({ invoiceType: 'INTERIM' as InvoiceType, notes: '', }); + const [contractDocumentData, setContractDocumentData] = useState({ + documentType: CONTRACT_DOCUMENT_TYPES[0], + notes: '', + deliveryDate: new Date().toISOString().split('T')[0], + }); const queryClient = useQueryClient(); // Ziele laden (gleiche wie bei Anhängen) @@ -51,8 +67,10 @@ export default function SaveEmailAsPdfModal({ const targets = targetsData?.data; - // Prüfen ob es ein Energievertrag ist + // Prüfen ob es ein Energievertrag ist (für Rechnungs-Modus) const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS'; + // Vertrag zugeordnet? → Vertragsdokument-Modus erlaubt + const hasContract = !!targets?.contract; const saveMutation = useMutation({ mutationFn: () => { @@ -111,6 +129,33 @@ export default function SaveEmailAsPdfModal({ }, }); + const saveContractDocumentMutation = useMutation({ + mutationFn: () => { + const isDelivery = contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung'; + return cachedEmailApi.saveEmailAsContractDocument(emailId, { + documentType: contractDocumentData.documentType, + notes: contractDocumentData.notes || undefined, + deliveryDate: isDelivery ? contractDocumentData.deliveryDate : undefined, + }); + }, + onSuccess: () => { + toast.success('E-Mail 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'); @@ -119,6 +164,11 @@ export default function SaveEmailAsPdfModal({ invoiceType: 'INTERIM', notes: '', }); + setContractDocumentData({ + documentType: CONTRACT_DOCUMENT_TYPES[0], + notes: '', + deliveryDate: new Date().toISOString().split('T')[0], + }); onClose(); }; @@ -235,7 +285,7 @@ export default function SaveEmailAsPdfModal({ ); }; - const isPending = saveMutation.isPending || saveInvoiceMutation.isPending; + const isPending = saveMutation.isPending || saveInvoiceMutation.isPending || saveContractDocumentMutation.isPending; return ( @@ -263,12 +313,13 @@ export default function SaveEmailAsPdfModal({ {targets && ( <> - {/* Mode Toggle für Energieverträge */} - {isEnergyContract && ( + {/* Mode Toggle: Vertragsdokument-Tab wenn ein Vertrag verknüpft ist, + Rechnungs-Tab nur bei Energieverträgen. */} + {hasContract && (
+ {isEnergyContract && ( + + )}
)} @@ -377,6 +441,46 @@ export default function SaveEmailAsPdfModal({ /> )} + + {/* Contract Document Mode */} + {saveMode === 'contractDocument' && hasContract && ( +
+
+

+ Die E-Mail wird als Vertragsdokument für den Vertrag {targets.contract?.contractNumber} gespeichert. +

+
+ + setContractDocumentData({ ...contractDocumentData, notes: e.target.value })} + placeholder="Optionale Anmerkungen..." + /> + + {contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung' && ( +
+ + setContractDocumentData({ ...contractDocumentData, deliveryDate: e.target.value })} + className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +

+ Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen. +

+
+ )} +
+ )} )} @@ -396,14 +500,15 @@ export default function SaveEmailAsPdfModal({ - {saveMode === 'document' ? ( + {saveMode === 'document' && ( - ) : ( + )} + {saveMode === 'invoice' && ( )} + {saveMode === 'contractDocument' && ( + + )}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index fb85deac..ff04c551 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -710,6 +710,18 @@ export const cachedEmailApi = { ); return res.data; }, + // E-Mail als Vertragsdokument speichern (PDF aus der Mail wird als + // ContractDocument hinterlegt – parallel zur Anhang-Variante) + saveEmailAsContractDocument: async ( + emailId: number, + params: { documentType: string; notes?: string; deliveryDate?: string }, + ) => { + const res = await api.post>( + `/emails/${emailId}/save-as-contract-document`, + params, + ); + return res.data; + }, // Anhang als Rechnung speichern (für Energieverträge) saveAttachmentAsInvoice: async (emailId: number, filename: string, params: { invoiceDate: string; invoiceType: string; notes?: string }) => { const encodedFilename = encodeURIComponent(filename);