diff --git a/backend/.gitignore b/backend/.gitignore index f47e686a..e22acb5f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -13,6 +13,10 @@ dist/ prisma/backups/* !prisma/backups/.gitkeep +# Uploads (user files, keep folder structure) +uploads/* +!uploads/.gitkeep + # Logs *.log npm-debug.log* diff --git a/backend/src/services/pdfService.ts b/backend/src/services/pdfService.ts new file mode 100644 index 00000000..e42fee34 --- /dev/null +++ b/backend/src/services/pdfService.ts @@ -0,0 +1,153 @@ +import puppeteer from 'puppeteer'; + +/** + * Konvertiert HTML zu PDF mit Puppeteer + */ +export async function htmlToPdf(html: string): Promise { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'networkidle0' }); + + const pdfBuffer = await page.pdf({ + format: 'A4', + margin: { + top: '20mm', + right: '15mm', + bottom: '20mm', + left: '15mm', + }, + printBackground: true, + }); + + return Buffer.from(pdfBuffer); + } finally { + await browser.close(); + } +} + +/** + * Baut ein HTML-Dokument für eine E-Mail mit Header + */ +export function buildEmailHtml(email: { + subject?: string | null; + fromAddress: string; + fromName?: string | null; + toAddresses: string; + receivedAt: Date; + htmlBody?: string | null; + textBody?: string | null; +}): string { + const formatDate = (date: Date) => { + return new Date(date).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + // To-Adressen parsen (JSON Array) + let toList: string[] = []; + try { + toList = JSON.parse(email.toAddresses); + } catch { + toList = [email.toAddresses]; + } + + const fromDisplay = email.fromName + ? `${email.fromName} <${email.fromAddress}>` + : email.fromAddress; + + const body = email.htmlBody || `
${email.textBody || ''}
`; + + return ` + + + + + + + +
+

${escapeHtml(email.subject || '(Kein Betreff)')}

+ + + + + + + + + + + + + +
Von:${escapeHtml(fromDisplay)}
An:${escapeHtml(toList.join(', '))}
Datum:${formatDate(email.receivedAt)}
+
+
+ ${body} +
+ + + `.trim(); +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/frontend/src/components/email/SaveEmailAsPdfModal.tsx b/frontend/src/components/email/SaveEmailAsPdfModal.tsx new file mode 100644 index 00000000..68ceb86b --- /dev/null +++ b/frontend/src/components/email/SaveEmailAsPdfModal.tsx @@ -0,0 +1,293 @@ +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 SaveEmailAsPdfModalProps { + isOpen: boolean; + onClose: () => void; + emailId: number; + onSuccess?: () => void; +} + +type SelectedTarget = { + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract'; + entityId?: number; + targetKey: string; + hasDocument: boolean; + label: string; +}; + +export default function SaveEmailAsPdfModal({ + isOpen, + onClose, + emailId, + onSuccess, +}: SaveEmailAsPdfModalProps) { + const [selectedTarget, setSelectedTarget] = useState(null); + const [expandedSections, setExpandedSections] = useState>(new Set(['customer'])); + const queryClient = useQueryClient(); + + // Ziele laden (gleiche wie bei Anhängen) + 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.saveEmailAsPdf(emailId, { + entityType: selectedTarget.entityType, + entityId: selectedTarget.entityId, + targetKey: selectedTarget.targetKey, + }); + }, + onSuccess: () => { + toast.success('E-Mail als PDF gespeichert'); + queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] }); + queryClient.invalidateQueries({ queryKey: ['customers'] }); + queryClient.invalidateQueries({ queryKey: ['contracts'] }); + + // Spezifische Ansichten aktualisieren + 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 ( + +
+ {/* Info */} +
+

+ Die E-Mail wird als PDF exportiert (inkl. Absender, Empfänger, Datum und Inhalt) und im gewählten Dokumentenfeld gespeichert. +

+
+ + {/* 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 die PDF ersetzt. +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +}