From 29eceef26b04ec30a7c3d977d46e36d8da3d3a23 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 5 Apr 2026 19:16:47 +0200 Subject: [PATCH] =?UTF-8?q?PDF-Auftragsvorlagen-System,=20Objekttyp/Lage-F?= =?UTF-8?q?elder,=20Eigent=C3=BCmer-Fallback=20bei=20Bankverbindung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen - PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder - Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail) - Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl - Nicht zugeordnete Felder bleiben editierbar im generierten PDF - Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten - Stressfrei-E-Mail als Feld-Option im Template-Editor - Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel) - Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/package-lock.json | 55 ++ backend/package.json | 1 + backend/prisma/schema.prisma | 31 + .../src/controllers/customer.controller.ts | 5 + .../src/controllers/pdfTemplate.controller.ts | 200 ++++++ backend/src/index.ts | 2 + backend/src/routes/pdfTemplate.routes.ts | 53 ++ backend/src/services/pdfTemplate.service.ts | 633 ++++++++++++++++++ backend/todo.md | 50 +- frontend/src/App.tsx | 2 + frontend/src/pages/Settings.tsx | 17 + .../src/pages/contracts/ContractDetail.tsx | 163 ++++- .../src/pages/customers/CustomerDetail.tsx | 104 ++- frontend/src/pages/settings/PdfTemplates.tsx | 498 ++++++++++++++ frontend/src/services/api.ts | 49 ++ frontend/src/types/index.ts | 39 ++ 16 files changed, 1881 insertions(+), 21 deletions(-) create mode 100644 backend/src/controllers/pdfTemplate.controller.ts create mode 100644 backend/src/routes/pdfTemplate.routes.ts create mode 100644 backend/src/services/pdfTemplate.service.ts create mode 100644 frontend/src/pages/settings/PdfTemplates.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 278752bb..b27cd25f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "mailparser": "^3.9.3", "multer": "^1.4.5-lts.1", "nodemailer": "^7.0.13", + "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "undici": "^6.23.0" }, @@ -473,6 +474,36 @@ "node": ">=12" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pdf-lib/upng/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2478,6 +2509,30 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pdfkit": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", diff --git a/backend/package.json b/backend/package.json index 4922a230..481dce34 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "mailparser": "^3.9.3", "multer": "^1.4.5-lts.1", "nodemailer": "^7.0.13", + "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "undici": "^6.23.0" }, diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9c1915b7..a6598c13 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -7,6 +7,26 @@ datasource db { url = env("DATABASE_URL") } +// ==================== PDF TEMPLATES (Auftragsvorlagen) ==================== + +model PdfTemplate { + id Int @id @default(autoincrement()) + name String @unique // z.B. "EWE Auftragsformular" + description String? // Beschreibung + providerName String? // Zugehöriger Anbieter (z.B. "EWE") + templatePath String // Pfad zur PDF-Vorlage + originalName String // Originaler Dateiname + // Feld-Mapping: JSON-Objekt { pdfFieldName: crmFieldPath } + // z.B. { "Vorname": "customer.firstName", "PLZ": "customer.addresses[0].postalCode" } + fieldMapping String @db.LongText // JSON + // Rufnummern-Konfiguration + phoneFieldPrefix String? // Prefix für Rufnummern-Felder (z.B. "Rufnummer") + maxPhoneFields Int? @default(8) // Max. Rufnummern-Felder im PDF + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // ==================== EMAIL LOG ==================== model EmailLog { @@ -224,6 +244,17 @@ model Address { city String country String @default("Deutschland") isDefault Boolean @default(false) + // Eigentümer (leer = Kunde ist selbst Eigentümer) + ownerCompany String? + ownerFirstName String? + ownerLastName String? + ownerStreet String? + ownerHouseNumber String? + ownerPostalCode String? + ownerCity String? + ownerPhone String? + ownerMobile String? + ownerEmail String? contractsAsDelivery Contract[] @relation("DeliveryAddress") contractsAsBilling Contract[] @relation("BillingAddress") createdAt DateTime @default(now()) diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 0525f568..6759e20b 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -202,6 +202,11 @@ export async function updateAddress(req: Request, res: Response): Promise const fieldLabels: Record = { street: 'Straße', houseNumber: 'Hausnummer', postalCode: 'PLZ', city: 'Stadt', country: 'Land', type: 'Typ', isDefault: 'Standard', + ownerCompany: 'Eigentümer Firma', ownerFirstName: 'Eigentümer Vorname', + ownerLastName: 'Eigentümer Nachname', ownerStreet: 'Eigentümer Straße', + ownerHouseNumber: 'Eigentümer Hausnr.', ownerPostalCode: 'Eigentümer PLZ', + ownerCity: 'Eigentümer Ort', ownerPhone: 'Eigentümer Telefon', + ownerMobile: 'Eigentümer Mobil', ownerEmail: 'Eigentümer E-Mail', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].includes(key)) continue; diff --git a/backend/src/controllers/pdfTemplate.controller.ts b/backend/src/controllers/pdfTemplate.controller.ts new file mode 100644 index 00000000..fcf08c32 --- /dev/null +++ b/backend/src/controllers/pdfTemplate.controller.ts @@ -0,0 +1,200 @@ +import { Response } from 'express'; +import { AuthRequest } from '../types/index.js'; +import * as pdfTemplateService from '../services/pdfTemplate.service.js'; +import { logChange } from '../services/audit.service.js'; + +export async function getTemplates(req: AuthRequest, res: Response) { + try { + const templates = await pdfTemplateService.getAllTemplates(); + res.json({ success: true, data: templates }); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Laden' }); + } +} + +export async function getTemplate(req: AuthRequest, res: Response) { + try { + const template = await pdfTemplateService.getTemplateById(parseInt(req.params.id)); + if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' }); + res.json({ success: true, data: template }); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Laden' }); + } +} + +export async function createTemplate(req: AuthRequest, res: Response) { + try { + if (!req.file) return res.status(400).json({ success: false, error: 'PDF-Datei erforderlich' }); + + const { name, description, providerName, phoneFieldPrefix, maxPhoneFields } = req.body; + const templatePath = `/uploads/pdf-templates/${req.file.filename}`; + + // PDF-Felder auslesen + let pdfFields: { name: string; type: string; page: number; y: number }[] = []; + try { + const extracted = await pdfTemplateService.extractPdfFields(templatePath); + pdfFields = extracted.fields; + } catch { + // PDF hat keine Formularfelder - OK, kann trotzdem gespeichert werden + } + + const template = await pdfTemplateService.createTemplate({ + name, + description, + providerName, + templatePath, + originalName: req.file.originalname, + phoneFieldPrefix, + maxPhoneFields: maxPhoneFields ? parseInt(maxPhoneFields) : undefined, + }); + + await logChange({ + req, action: 'CREATE', resourceType: 'PdfTemplate', + resourceId: template.id.toString(), + label: `Auftragsvorlage "${name}" angelegt`, + }); + + res.status(201).json({ success: true, data: { ...template, pdfFields } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Erstellen', + }); + } +} + +export async function updateTemplate(req: AuthRequest, res: Response) { + try { + const id = parseInt(req.params.id); + const { name, description, providerName, fieldMapping, phoneFieldPrefix, maxPhoneFields, isActive } = req.body; + + const template = await pdfTemplateService.updateTemplate(id, { + name, + description, + providerName, + fieldMapping: fieldMapping ? JSON.stringify(fieldMapping) : undefined, + phoneFieldPrefix, + maxPhoneFields: maxPhoneFields !== undefined ? parseInt(maxPhoneFields) : undefined, + isActive, + }); + + await logChange({ + req, action: 'UPDATE', resourceType: 'PdfTemplate', + resourceId: id.toString(), + label: `Auftragsvorlage "${template.name}" aktualisiert`, + }); + + res.json({ success: true, data: template }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren', + }); + } +} + +export async function deleteTemplate(req: AuthRequest, res: Response) { + try { + const id = parseInt(req.params.id); + const template = await pdfTemplateService.getTemplateById(id); + await pdfTemplateService.deleteTemplate(id); + await logChange({ + req, action: 'DELETE', resourceType: 'PdfTemplate', + resourceId: id.toString(), + label: `Auftragsvorlage "${template?.name}" gelöscht`, + }); + res.json({ success: true, message: 'Vorlage gelöscht' }); + } catch (error) { + res.status(400).json({ success: false, error: 'Fehler beim Löschen' }); + } +} + +export async function extractFields(req: AuthRequest, res: Response) { + try { + const id = parseInt(req.params.id); + const template = await pdfTemplateService.getTemplateById(id); + if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' }); + + const result = await pdfTemplateService.extractPdfFields(template.templatePath); + res.json({ success: true, data: result.fields, totalPages: result.totalPages }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Auslesen der PDF-Felder', + }); + } +} + +export async function getAnnotatedPreview(req: AuthRequest, res: Response) { + try { + const id = parseInt(req.params.id); + const template = await pdfTemplateService.getTemplateById(id); + if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' }); + + const pdfBuffer = await pdfTemplateService.generateAnnotatedPreview(template.templatePath); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"'); + res.send(pdfBuffer); + } catch (error) { + res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' }); + } +} + +export async function getCrmFields(req: AuthRequest, res: Response) { + const maxPhoneFields = req.query.maxPhoneFields ? parseInt(req.query.maxPhoneFields as string) : 8; + res.json({ success: true, data: pdfTemplateService.getCrmFieldsForTemplate(maxPhoneFields) }); +} + +export async function getRequiredInputs(req: AuthRequest, res: Response) { + try { + const templateId = parseInt(req.params.id); + const contractId = parseInt(req.params.contractId); + const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId); + res.json({ success: true, data: inputs }); + } catch (error) { + res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' }); + } +} + +export async function generatePdf(req: AuthRequest, res: Response) { + try { + const templateId = parseInt(req.params.id); + const contractId = parseInt(req.params.contractId); + + // Extras aus Body (POST) oder Query-Parametern (GET) + const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId; + const manualValues: Record = req.body?.manualValues || {}; + + // Manual-Werte aus Query-Parametern extrahieren (manual_manual:1=Wert) + if (req.query) { + for (const [key, value] of Object.entries(req.query)) { + if (key.startsWith('manual_') && typeof value === 'string') { + manualValues[key.replace('manual_', '')] = value; + } + } + } + + const pdfBuffer = await pdfTemplateService.generateFilledPdf(templateId, contractId, { + stressfreiEmailId: stressfreiEmailId ? parseInt(stressfreiEmailId as string) : undefined, + manualValues: Object.keys(manualValues).length > 0 ? manualValues : undefined, + }); + + const template = await pdfTemplateService.getTemplateById(templateId); + const filename = `${template?.name || 'Auftrag'}_${new Date().toISOString().split('T')[0]}.pdf`; + + await logChange({ + req, action: 'CREATE', resourceType: 'GeneratedPdf', + label: `PDF "${template?.name}" generiert für Vertrag #${contractId}`, + }); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`); + res.send(pdfBuffer); + } catch (error) { + console.error('PDF generate error:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Generieren', + }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 644aa561..7a4a741c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,7 @@ import auditLogRoutes from './routes/auditLog.routes.js'; import gdprRoutes from './routes/gdpr.routes.js'; import consentPublicRoutes from './routes/consent-public.routes.js'; import emailLogRoutes from './routes/emailLog.routes.js'; +import pdfTemplateRoutes from './routes/pdfTemplate.routes.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; @@ -79,6 +80,7 @@ app.use('/api', contractHistoryRoutes); app.use('/api/audit-logs', auditLogRoutes); app.use('/api/gdpr', gdprRoutes); app.use('/api/email-logs', emailLogRoutes); +app.use('/api/pdf-templates', pdfTemplateRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/routes/pdfTemplate.routes.ts b/backend/src/routes/pdfTemplate.routes.ts new file mode 100644 index 00000000..0f003c66 --- /dev/null +++ b/backend/src/routes/pdfTemplate.routes.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { authenticate, requirePermission } from '../middleware/auth.js'; +import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js'; + +const router = Router(); + +// Upload-Verzeichnis +const templatesDir = path.join(process.cwd(), 'uploads', 'pdf-templates'); +if (!fs.existsSync(templatesDir)) { + fs.mkdirSync(templatesDir, { recursive: true }); +} + +const upload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, templatesDir), + filename: (_req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + cb(null, `template-${uniqueSuffix}${path.extname(file.originalname)}`); + }, + }), + fileFilter: (_req, file, cb) => { + if (file.mimetype === 'application/pdf') cb(null, true); + else cb(new Error('Nur PDF-Dateien sind erlaubt')); + }, + limits: { fileSize: 20 * 1024 * 1024 }, +}); + +router.use(authenticate); + +// CRUD +router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates); +router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields); +router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate); +router.post('/', requirePermission('settings:update'), upload.single('template'), pdfTemplateController.createTemplate); +router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate); +router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate); + +// PDF-Felder auslesen +router.get('/:id/fields', requirePermission('settings:read'), pdfTemplateController.extractFields); + +// Annotierte Vorschau (Feldnamen in der PDF sichtbar) +router.get('/:id/preview', requirePermission('settings:read'), pdfTemplateController.getAnnotatedPreview); + +// PDF generieren +router.get('/:id/generate/:contractId/inputs', requirePermission('contracts:read'), pdfTemplateController.getRequiredInputs); +router.post('/:id/generate/:contractId', requirePermission('contracts:read'), pdfTemplateController.generatePdf); +// Auch GET für direkte Links (ohne Extras) +router.get('/:id/generate/:contractId', requirePermission('contracts:read'), pdfTemplateController.generatePdf); + +export default router; diff --git a/backend/src/services/pdfTemplate.service.ts b/backend/src/services/pdfTemplate.service.ts new file mode 100644 index 00000000..0035addd --- /dev/null +++ b/backend/src/services/pdfTemplate.service.ts @@ -0,0 +1,633 @@ +import { PDFDocument, PDFTextField, PDFCheckBox, PDFDropdown, PDFName } from 'pdf-lib'; +import fs from 'fs'; +import path from 'path'; +import prisma from '../lib/prisma.js'; + +// ==================== VERFÜGBARE CRM-FELDER ==================== + +export const CRM_FIELDS = [ + // Kunde + { path: 'customer.salutation', label: 'Anrede', group: 'Kunde' }, + { path: 'customer.firstName', label: 'Vorname', group: 'Kunde' }, + { path: 'customer.lastName', label: 'Nachname', group: 'Kunde' }, + { path: 'customer.fullName', label: 'Voller Name (Vor- + Nachname)', group: 'Kunde' }, + { path: 'customer.customerNumber', label: 'Kundennummer', group: 'Kunde' }, + { path: 'customer.email', label: 'E-Mail', group: 'Kunde' }, + { path: 'customer.phone', label: 'Telefon', group: 'Kunde' }, + { path: 'customer.mobile', label: 'Mobil', group: 'Kunde' }, + { path: 'customer.birthDate', label: 'Geburtsdatum', group: 'Kunde' }, + { path: 'customer.birthPlace', label: 'Geburtsort', group: 'Kunde' }, + { path: 'customer.companyName', label: 'Firma', group: 'Kunde' }, + { path: 'customer.type', label: 'Kundentyp (Privat/Firma)', group: 'Kunde' }, + { path: 'customer.taxNumber', label: 'Steuernummer', group: 'Kunde' }, + // Adresse (Lieferadresse) + { path: 'address.street', label: 'Straße', group: 'Adresse' }, + { path: 'address.houseNumber', label: 'Hausnummer', group: 'Adresse' }, + { path: 'address.streetFull', label: 'Straße + Hausnummer', group: 'Adresse' }, + { path: 'address.postalCode', label: 'PLZ', group: 'Adresse' }, + { path: 'address.city', label: 'Stadt', group: 'Adresse' }, + { path: 'address.postalCodeCity', label: 'PLZ + Stadt', group: 'Adresse' }, + { path: 'address.country', label: 'Land', group: 'Adresse' }, + { path: 'address.full', label: 'Vollständige Adresse', group: 'Adresse' }, + // Eigentümer (der Lieferadresse) + { path: 'owner.company', label: 'Firma', group: 'Eigentümer' }, + { path: 'owner.firstName', label: 'Vorname', group: 'Eigentümer' }, + { path: 'owner.lastName', label: 'Nachname', group: 'Eigentümer' }, + { path: 'owner.fullName', label: 'Vorname Nachname', group: 'Eigentümer' }, + { path: 'owner.companyFirstNameLastName', label: 'Firma + Vorname + Nachname', group: 'Eigentümer' }, + { path: 'owner.companyLastNameFirstName', label: 'Firma + Nachname + Vorname', group: 'Eigentümer' }, + { path: 'owner.companyFirstName', label: 'Firma + Vorname', group: 'Eigentümer' }, + { path: 'owner.companyLastName', label: 'Firma + Nachname', group: 'Eigentümer' }, + { path: 'owner.street', label: 'Straße', group: 'Eigentümer' }, + { path: 'owner.houseNumber', label: 'Hausnummer', group: 'Eigentümer' }, + { path: 'owner.postalCode', label: 'PLZ', group: 'Eigentümer' }, + { path: 'owner.city', label: 'Ort', group: 'Eigentümer' }, + { path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' }, + { path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' }, + { path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' }, + // Rechnungsadresse + { path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' }, + { path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' }, + { path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' }, + { path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' }, + { path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' }, + { path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' }, + // Bankverbindung + { path: 'bankCard.iban', label: 'IBAN', group: 'Bank' }, + { path: 'bankCard.bic', label: 'BIC', group: 'Bank' }, + { path: 'bankCard.bankName', label: 'Bank', group: 'Bank' }, + { path: 'bankCard.accountHolder', label: 'Kontoinhaber', group: 'Bank' }, + // Vertrag + { path: 'contract.contractNumber', label: 'Vertragsnummer', group: 'Vertrag' }, + { path: 'contract.type', label: 'Vertragstyp', group: 'Vertrag' }, + { path: 'contract.status', label: 'Status', group: 'Vertrag' }, + { path: 'contract.startDate', label: 'Vertragsbeginn', group: 'Vertrag' }, + { path: 'contract.endDate', label: 'Vertragsende', group: 'Vertrag' }, + { path: 'contract.providerName', label: 'Anbieter', group: 'Vertrag' }, + { path: 'contract.tariffName', label: 'Tarif', group: 'Vertrag' }, + { path: 'contract.customerNumberAtProvider', label: 'Kundennr. beim Anbieter', group: 'Vertrag' }, + { path: 'contract.cancellationDate', label: 'Kündigungsdatum', group: 'Vertrag' }, + { path: 'contract.commission', label: 'Provision', group: 'Vertrag' }, + { path: 'contract.platformName', label: 'Vertriebsplattform', group: 'Vertrag' }, + { path: 'contract.notes', label: 'Notizen', group: 'Vertrag' }, + // Altanbieter + { path: 'contract.previousProviderName', label: 'Altanbieter', group: 'Altanbieter' }, + { path: 'contract.previousCustomerNumber', label: 'Kundennr. Altanbieter', group: 'Altanbieter' }, + // Ausweis + { path: 'identityDocument.type', label: 'Dokumenttyp', group: 'Ausweis' }, + { path: 'identityDocument.documentNumber', label: 'Ausweisnummer', group: 'Ausweis' }, + { path: 'identityDocument.issuingAuthority', label: 'Ausstellungsbehörde', group: 'Ausweis' }, + { path: 'identityDocument.issueDate', label: 'Ausstellungsdatum', group: 'Ausweis' }, + { path: 'identityDocument.expiryDate', label: 'Gültig bis', group: 'Ausweis' }, + // Zähler (Energie) + { path: 'meter.meterNumber', label: 'Zählernummer', group: 'Energie' }, + { path: 'meter.type', label: 'Zählertyp (Strom/Gas)', group: 'Energie' }, + { path: 'energyDetails.maloId', label: 'MaLo-ID', group: 'Energie' }, + { path: 'energyDetails.annualConsumption', label: 'Jahresverbrauch', group: 'Energie' }, + { path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' }, + { path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' }, + { path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' }, + { path: 'energyDetails.bonus', label: 'Bonus (€)', group: 'Energie' }, + // Internet/DSL/Glasfaser/Kabel + { path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' }, + { path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' }, + { path: 'internetDetails.routerModel', label: 'Router-Modell', group: 'Internet' }, + { path: 'internetDetails.routerSerialNumber', label: 'Router-Seriennummer', group: 'Internet' }, + { path: 'internetDetails.installationDate', label: 'Installationsdatum', group: 'Internet' }, + { path: 'internetDetails.internetUsername', label: 'Internet-Benutzername', group: 'Internet' }, + { path: 'internetDetails.homeId', label: 'Home-ID', group: 'Internet' }, + { path: 'internetDetails.activationCode', label: 'Aktivierungscode', group: 'Internet' }, + { path: 'internetDetails.propertyType', label: 'Objekttyp', group: 'Internet' }, + { path: 'internetDetails.propertyLocation', label: 'Lage', group: 'Internet' }, + { path: 'internetDetails.connectionLocation', label: 'Anschluss-Lage', group: 'Internet' }, + // Mobilfunk + { path: 'mobileDetails.phoneNumber', label: 'Mobilfunknummer', group: 'Mobilfunk' }, + { path: 'mobileDetails.simCardNumber', label: 'SIM-Kartennummer', group: 'Mobilfunk' }, + { path: 'mobileDetails.dataVolume', label: 'Datenvolumen', group: 'Mobilfunk' }, + { path: 'mobileDetails.deviceName', label: 'Gerätename', group: 'Mobilfunk' }, + { path: 'mobileDetails.deviceImei', label: 'IMEI', group: 'Mobilfunk' }, + // Rufnummern werden dynamisch generiert über getCrmFieldsForTemplate() + // Stressfrei-Wechseln E-Mail + { path: 'stressfreiEmail', label: 'Stressfrei-E-Mail (Auswahl beim Generieren)', group: 'Stressfrei-Wechseln' }, + { path: 'stressfreiEmail.password', label: 'Stressfrei-E-Mail Passwort', group: 'Stressfrei-Wechseln' }, + // Sonstiges (individuelle Werte) + { path: 'today', label: 'Heutiges Datum', group: 'Sonstiges' }, + { path: 'static:true', label: 'Checkbox: Angehakt', group: 'Sonstiges' }, + { path: 'static:false', label: 'Checkbox: Nicht angehakt', group: 'Sonstiges' }, + { path: 'static:X', label: 'Text: X (Kreuz)', group: 'Sonstiges' }, + { path: 'static:Ja', label: 'Text: Ja', group: 'Sonstiges' }, + { path: 'static:Nein', label: 'Text: Nein', group: 'Sonstiges' }, + { path: 'static:SEPA', label: 'Text: SEPA-Lastschrift', group: 'Sonstiges' }, + { path: 'static:Privatkunde', label: 'Text: Privatkunde', group: 'Sonstiges' }, + { path: 'static:Geschäftskunde', label: 'Text: Geschäftskunde', group: 'Sonstiges' }, + { path: 'static:Herr', label: 'Text: Herr', group: 'Sonstiges' }, + { path: 'static:Frau', label: 'Text: Frau', group: 'Sonstiges' }, + { path: 'static:Anbieterwechsel', label: 'Text: Anbieterwechsel', group: 'Sonstiges' }, + { path: 'static:Neuanschluss', label: 'Text: Neuanschluss', group: 'Sonstiges' }, + { path: 'static:Umzug', label: 'Text: Umzug', group: 'Sonstiges' }, + // Freitextfelder (leer lassen zum manuellen Ausfüllen) + { path: 'manual:1', label: 'Freitext 1 (manuell beim Generieren)', group: 'Freitext' }, + { path: 'manual:2', label: 'Freitext 2 (manuell beim Generieren)', group: 'Freitext' }, + { path: 'manual:3', label: 'Freitext 3 (manuell beim Generieren)', group: 'Freitext' }, + { path: 'manual:4', label: 'Freitext 4 (manuell beim Generieren)', group: 'Freitext' }, + { path: 'manual:5', label: 'Freitext 5 (manuell beim Generieren)', group: 'Freitext' }, +]; + +/** + * Generiert die vollständige CRM-Feldliste inkl. dynamischer Rufnummern + */ +export function getCrmFieldsForTemplate(maxPhoneFields: number = 8) { + const phoneFields = []; + for (let i = 0; i < maxPhoneFields; i++) { + const n = i + 1; + phoneFields.push( + { path: `phoneNumbers[${i}]`, label: `Vorwahl+Rufnummer ${n}`, group: 'Rufnummern' }, + { path: `phoneAreaCode[${i}]`, label: `Vorwahl ${n}`, group: 'Rufnummern' }, + { path: `phoneLocal[${i}]`, label: `Rufnummer ${n} (ohne Vorwahl)`, group: 'Rufnummern' }, + ); + } + return [...CRM_FIELDS, ...phoneFields]; +} + +// ==================== PDF-FELDER AUSLESEN ==================== + +export async function extractPdfFields(pdfPath: string): Promise<{ fields: { name: string; type: string; page: number; y: number }[]; totalPages: number }> { + const pdfBytes = fs.readFileSync(path.join(process.cwd(), pdfPath)); + const pdfDoc = await PDFDocument.load(pdfBytes); + const form = pdfDoc.getForm(); + const fields = form.getFields(); + const pages = pdfDoc.getPages(); + + const totalPages = pages.length; + + // Page-Refs sammeln: Jede Seite hat eine interne Referenz + const pageRefs: string[] = pages.map(p => String((p as any).ref)); + + const result: { name: string; type: string; page: number; y: number }[] = []; + + for (const field of fields) { + const widgets = field.acroField.getWidgets(); + let pageIndex = 0; + let yPos = 0; + + if (widgets.length > 0) { + const widget = widgets[0]; + const rect = widget.getRectangle(); + + // Seitenzuordnung über /P Eintrag im Widget + try { + const pRef = widget.dict.get(PDFName.of('P')); + if (pRef) { + const pRefStr = String(pRef); + const idx = pageRefs.indexOf(pRefStr); + if (idx >= 0) pageIndex = idx; + } + } catch { /* ignore */ } + + const pageHeight = pages[pageIndex]?.getHeight() || 842; + yPos = Math.round(pageHeight - rect.y); + } + + result.push({ + name: field.getName(), + type: field.constructor.name.replace('PDF', '').replace('Field', ''), + page: pageIndex, + y: yPos, + }); + } + + // Sortieren: erst nach Seite, dann nach Y-Position (oben nach unten) + result.sort((a, b) => a.page !== b.page ? a.page - b.page : a.y - b.y); + + return { fields: result, totalPages }; +} + +/** + * Generiert eine annotierte PDF-Vorschau: Alle Formularfelder werden mit ihrem + * Feldnamen als Text befüllt, damit man sieht wo welches Feld ist. + */ +export async function generateAnnotatedPreview(pdfPath: string): Promise { + const pdfBytes = fs.readFileSync(path.join(process.cwd(), pdfPath)); + const pdfDoc = await PDFDocument.load(pdfBytes); + const form = pdfDoc.getForm(); + const fields = form.getFields(); + + for (const field of fields) { + const name = field.getName(); + try { + if (field instanceof PDFTextField) { + field.setText(`[${name}]`); + } else if (field instanceof PDFCheckBox) { + // Checkboxen angehakt lassen damit man sie sieht + field.check(); + } else if (field instanceof PDFDropdown) { + // Dropdown-Name als Text nicht möglich, ignorieren + } + } catch { /* ignore */ } + } + + // Alle Felder flatten damit sie als Text sichtbar sind + form.flatten(); + + return Buffer.from(await pdfDoc.save()); +} + +// ==================== TEMPLATE CRUD ==================== + +export async function getAllTemplates() { + return prisma.pdfTemplate.findMany({ + orderBy: { name: 'asc' }, + }); +} + +export async function getTemplateById(id: number) { + return prisma.pdfTemplate.findUnique({ where: { id } }); +} + +export async function createTemplate(data: { + name: string; + description?: string; + providerName?: string; + templatePath: string; + originalName: string; + fieldMapping?: string; + phoneFieldPrefix?: string; + maxPhoneFields?: number; +}) { + return prisma.pdfTemplate.create({ + data: { + name: data.name, + description: data.description, + providerName: data.providerName, + templatePath: data.templatePath, + originalName: data.originalName, + fieldMapping: data.fieldMapping || '{}', + phoneFieldPrefix: data.phoneFieldPrefix, + maxPhoneFields: data.maxPhoneFields ?? 8, + }, + }); +} + +export async function updateTemplate(id: number, data: { + name?: string; + description?: string; + providerName?: string; + fieldMapping?: string; + phoneFieldPrefix?: string; + maxPhoneFields?: number; + isActive?: boolean; +}) { + return prisma.pdfTemplate.update({ + where: { id }, + data, + }); +} + +export async function deleteTemplate(id: number) { + const template = await prisma.pdfTemplate.findUnique({ where: { id } }); + if (template?.templatePath) { + const filePath = path.join(process.cwd(), template.templatePath); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + return prisma.pdfTemplate.delete({ where: { id } }); +} + +// ==================== PDF GENERIEREN ==================== + +/** + * Generiert ein ausgefülltes PDF aus einem Template + Kundendaten + */ +/** + * Ermittelt welche Felder beim Generieren manuell eingegeben werden müssen + */ +export async function getRequiredInputs(templateId: number, contractId: number): Promise<{ + needsStressfreiEmail: boolean; + stressfreiEmails: { id: number; email: string }[]; + manualFields: { key: string; pdfFieldName: string }[]; +}> { + const template = await prisma.pdfTemplate.findUnique({ where: { id: templateId } }); + if (!template) throw new Error('Vorlage nicht gefunden'); + + const mapping: Record = JSON.parse(template.fieldMapping || '{}'); + + const needsStressfreiEmail = Object.values(mapping).some(v => v.startsWith('stressfreiEmail')); + const manualFields = Object.entries(mapping) + .filter(([_, v]) => v.startsWith('manual:')) + .map(([pdfFieldName, v]) => ({ key: v, pdfFieldName })); + + let stressfreiEmails: { id: number; email: string }[] = []; + if (needsStressfreiEmail) { + const contract = await prisma.contract.findUnique({ + where: { id: contractId }, + select: { customerId: true }, + }); + if (contract) { + const emails = await prisma.stressfreiEmail.findMany({ + where: { customerId: contract.customerId, isActive: true }, + select: { id: true, email: true }, + }); + stressfreiEmails = emails; + } + } + + return { needsStressfreiEmail, stressfreiEmails, manualFields }; +} + +export async function generateFilledPdf( + templateId: number, + contractId: number, + extras?: { + stressfreiEmailId?: number; + manualValues?: Record; + } +): Promise { + const template = await prisma.pdfTemplate.findUnique({ where: { id: templateId } }); + if (!template) throw new Error('Vorlage nicht gefunden'); + + // Vertrag mit allen Relationen laden + const contract = await prisma.contract.findUnique({ + where: { id: contractId }, + include: { + customer: true, + address: true, + billingAddress: true, + bankCard: true, + identityDocument: true, + provider: true, + tariff: true, + salesPlatform: true, + energyDetails: { include: { meter: true } }, + internetDetails: { include: { phoneNumbers: true } }, + mobileDetails: { include: { simCards: true } }, + stressfreiEmail: true, + }, + }); + + if (!contract) throw new Error('Vertrag nicht gefunden'); + + // Fallback: Wenn keine Bankverbindung am Vertrag, erste aktive des Kunden nehmen + let bankCard = contract.bankCard; + if (!bankCard && contract.customer) { + bankCard = await prisma.bankCard.findFirst({ + where: { customerId: contract.customer.id, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + // Daten-Kontext aufbauen + const formatDate = (d: Date | null | undefined) => { + if (!d) return ''; + return new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + }; + + const typeLabels: Record = { ELECTRICITY: 'Strom', GAS: 'Gas', DSL: 'DSL', CABLE: 'Kabelinternet', FIBER: 'Glasfaser', MOBILE: 'Mobilfunk', TV: 'TV', CAR_INSURANCE: 'KFZ-Versicherung' }; + const docTypeLabels: Record = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' }; + + const addr = contract.address; + const bAddr = contract.billingAddress; + + const dataContext: Record = { + // Kunde + 'customer.salutation': contract.customer?.salutation || '', + 'customer.firstName': contract.customer?.firstName || '', + 'customer.lastName': contract.customer?.lastName || '', + 'customer.fullName': `${contract.customer?.firstName || ''} ${contract.customer?.lastName || ''}`.trim(), + 'customer.customerNumber': contract.customer?.customerNumber || '', + 'customer.email': contract.customer?.email || '', + 'customer.phone': contract.customer?.phone || '', + 'customer.mobile': contract.customer?.mobile || '', + 'customer.birthDate': formatDate(contract.customer?.birthDate), + 'customer.birthPlace': contract.customer?.birthPlace || '', + 'customer.companyName': contract.customer?.companyName || '', + 'customer.type': contract.customer?.type === 'BUSINESS' ? 'Geschäftskunde' : 'Privatkunde', + 'customer.taxNumber': contract.customer?.taxNumber || '', + // Adresse + 'address.street': addr?.street || '', + 'address.houseNumber': addr?.houseNumber || '', + 'address.streetFull': addr ? `${addr.street} ${addr.houseNumber}` : '', + 'address.postalCode': addr?.postalCode || '', + 'address.city': addr?.city || '', + 'address.postalCodeCity': addr ? `${addr.postalCode} ${addr.city}` : '', + 'address.country': addr?.country || '', + 'address.full': addr ? `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}` : '', + // Eigentümer (aus Lieferadresse, Fallback: Kundendaten) + 'owner.company': addr?.ownerCompany || contract.customer?.companyName || '', + 'owner.firstName': addr?.ownerFirstName || contract.customer?.firstName || '', + 'owner.lastName': addr?.ownerLastName || contract.customer?.lastName || '', + 'owner.fullName': addr?.ownerFirstName + ? `${addr.ownerFirstName} ${addr.ownerLastName || ''}`.trim() + : `${contract.customer?.firstName || ''} ${contract.customer?.lastName || ''}`.trim(), + 'owner.companyFirstNameLastName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerFirstName || contract.customer?.firstName, addr?.ownerLastName || contract.customer?.lastName].filter(Boolean).join(' '), + 'owner.companyLastNameFirstName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerLastName || contract.customer?.lastName, addr?.ownerFirstName || contract.customer?.firstName].filter(Boolean).join(' '), + 'owner.companyFirstName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerFirstName || contract.customer?.firstName].filter(Boolean).join(' '), + 'owner.companyLastName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerLastName || contract.customer?.lastName].filter(Boolean).join(' '), + 'owner.street': addr?.ownerStreet || addr?.street || '', + 'owner.houseNumber': addr?.ownerHouseNumber || addr?.houseNumber || '', + 'owner.postalCode': addr?.ownerPostalCode || addr?.postalCode || '', + 'owner.city': addr?.ownerCity || addr?.city || '', + 'owner.phone': addr?.ownerPhone || contract.customer?.phone || '', + 'owner.mobile': addr?.ownerMobile || contract.customer?.mobile || '', + 'owner.email': addr?.ownerEmail || contract.customer?.email || '', + // Rechnungsadresse + 'billingAddress.street': bAddr?.street || '', + 'billingAddress.houseNumber': bAddr?.houseNumber || '', + 'billingAddress.streetFull': bAddr ? `${bAddr.street} ${bAddr.houseNumber}` : '', + 'billingAddress.postalCode': bAddr?.postalCode || '', + 'billingAddress.city': bAddr?.city || '', + 'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '', + // Bank + 'bankCard.iban': bankCard?.iban || '', + 'bankCard.bic': bankCard?.bic || '', + 'bankCard.bankName': bankCard?.bankName || '', + 'bankCard.accountHolder': bankCard?.accountHolder || '', + // Vertrag + 'contract.contractNumber': contract.contractNumber || '', + 'contract.type': typeLabels[contract.type] || contract.type, + 'contract.status': contract.status || '', + 'contract.startDate': formatDate(contract.startDate), + 'contract.endDate': formatDate(contract.endDate), + 'contract.providerName': contract.providerName || contract.provider?.name || '', + 'contract.tariffName': contract.tariffName || contract.tariff?.name || '', + 'contract.customerNumberAtProvider': contract.customerNumberAtProvider || '', + 'contract.cancellationDate': formatDate((contract as any).cancellationDate), + 'contract.commission': contract.commission?.toString() || '', + 'contract.platformName': contract.salesPlatform?.name || '', + 'contract.notes': contract.notes || '', + 'contract.previousProviderName': contract.energyDetails?.previousProviderName || '', + 'contract.previousCustomerNumber': contract.energyDetails?.previousCustomerNumber || '', + // Ausweis + 'identityDocument.type': docTypeLabels[contract.identityDocument?.type || ''] || '', + 'identityDocument.documentNumber': contract.identityDocument?.documentNumber || '', + 'identityDocument.issuingAuthority': contract.identityDocument?.issuingAuthority || '', + 'identityDocument.issueDate': formatDate(contract.identityDocument?.issueDate), + 'identityDocument.expiryDate': formatDate(contract.identityDocument?.expiryDate), + // Energie + 'meter.meterNumber': contract.energyDetails?.meter?.meterNumber || '', + 'meter.type': contract.energyDetails?.meter?.type === 'ELECTRICITY' ? 'Strom' : contract.energyDetails?.meter?.type === 'GAS' ? 'Gas' : '', + 'energyDetails.maloId': contract.energyDetails?.maloId || '', + 'energyDetails.annualConsumption': contract.energyDetails?.annualConsumption?.toString() || '', + 'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '', + 'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '', + 'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '', + 'energyDetails.bonus': contract.energyDetails?.bonus?.toString() || '', + // Internet + 'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '', + 'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '', + 'internetDetails.routerModel': contract.internetDetails?.routerModel || '', + 'internetDetails.routerSerialNumber': contract.internetDetails?.routerSerialNumber || '', + 'internetDetails.installationDate': formatDate(contract.internetDetails?.installationDate), + 'internetDetails.internetUsername': contract.internetDetails?.internetUsername || '', + 'internetDetails.homeId': contract.internetDetails?.homeId || '', + 'internetDetails.activationCode': contract.internetDetails?.activationCode || '', + 'internetDetails.propertyType': contract.internetDetails?.propertyType || '', + 'internetDetails.propertyLocation': contract.internetDetails?.propertyLocation || '', + 'internetDetails.connectionLocation': contract.internetDetails?.connectionLocation || '', + // Mobilfunk + 'mobileDetails.phoneNumber': contract.mobileDetails?.phoneNumber || '', + 'mobileDetails.simCardNumber': contract.mobileDetails?.simCardNumber || '', + 'mobileDetails.dataVolume': contract.mobileDetails?.dataVolume?.toString() || '', + 'mobileDetails.deviceName': contract.mobileDetails?.deviceImei || '', + 'mobileDetails.deviceImei': contract.mobileDetails?.deviceImei || '', + // Sonstiges + 'today': new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }), + // Statische Werte (für Checkboxen/Radio/individuelle Felder) + 'static:true': 'true', + 'static:false': 'false', + 'static:X': 'X', + 'static:Ja': 'Ja', + 'static:Nein': 'Nein', + 'static:SEPA': 'SEPA-Lastschrift', + 'static:Privatkunde': 'Privatkunde', + 'static:Geschäftskunde': 'Geschäftskunde', + 'static:Herr': 'Herr', + 'static:Frau': 'Frau', + 'static:Anbieterwechsel': 'Anbieterwechsel', + 'static:Neuanschluss': 'Neuanschluss', + 'static:Umzug': 'Umzug', + }; + + // Stressfrei-E-Mail + if (extras?.stressfreiEmailId) { + const sfEmail = await prisma.stressfreiEmail.findUnique({ + where: { id: extras.stressfreiEmailId }, + select: { email: true, emailPasswordEncrypted: true }, + }); + if (sfEmail) { + dataContext['stressfreiEmail'] = sfEmail.email; + if (sfEmail.emailPasswordEncrypted) { + try { + const { decrypt } = await import('../utils/encryption.js'); + dataContext['stressfreiEmail.password'] = decrypt(sfEmail.emailPasswordEncrypted); + } catch { /* ignore */ } + } + } + } else if (contract.stressfreiEmail) { + dataContext['stressfreiEmail'] = contract.stressfreiEmail.email; + } + + // Freitext-Felder (manuell beim Generieren eingegeben) + if (extras?.manualValues) { + for (const [key, value] of Object.entries(extras.manualValues)) { + dataContext[key] = value; + } + } + + // Rufnummern + Vorwahl-Extraktion + const phoneNumbers = contract.internetDetails?.phoneNumbers || []; + + /** + * Extrahiert Vorwahl und Rufnummer aus einer deutschen Festnetznummer. + * z.B. "04941 123456" → { areaCode: "04941", local: "123456" } + * z.B. "04941/123456" → { areaCode: "04941", local: "123456" } + * z.B. "04941-123456" → { areaCode: "04941", local: "123456" } + */ + const splitPhoneNumber = (phone: string): { areaCode: string; local: string } => { + if (!phone) return { areaCode: '', local: '' }; + const cleaned = phone.replace(/[()]/g, '').trim(); + // Trennzeichen: Leerzeichen, /, - + const match = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/); + if (match) return { areaCode: match[1], local: match[2].replace(/[\s\-\/]/g, '') }; + // Kein Trennzeichen: Versuche deutsche Vorwahl-Muster (2-5 Stellen nach 0) + const numMatch = cleaned.match(/^(0\d{1,4})(\d{3,})$/); + if (numMatch) return { areaCode: numMatch[1], local: numMatch[2] }; + return { areaCode: '', local: cleaned }; + }; + + const maxFields = template.maxPhoneFields || 8; + for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) { + const fullNumber = phoneNumbers[i]?.phoneNumber || ''; + const { areaCode, local } = splitPhoneNumber(fullNumber); + dataContext[`phoneNumbers[${i}]`] = fullNumber; + dataContext[`phoneAreaCode[${i}]`] = areaCode; + dataContext[`phoneLocal[${i}]`] = local; + } + + // PDF laden und befüllen + const pdfBytes = fs.readFileSync(path.join(process.cwd(), template.templatePath)); + const pdfDoc = await PDFDocument.load(pdfBytes); + const form = pdfDoc.getForm(); + + // Feld-Mapping anwenden + const mapping: Record = JSON.parse(template.fieldMapping || '{}'); + + for (const [pdfFieldName, crmFieldPath] of Object.entries(mapping)) { + const value = dataContext[crmFieldPath] || ''; + try { + const field = form.getField(pdfFieldName); + if (field instanceof PDFTextField) { + field.setText(value); + } else if (field instanceof PDFCheckBox) { + if (value === 'true' || value === 'Ja' || value === '1') { + field.check(); + } + } else if (field instanceof PDFDropdown) { + if (value) field.select(value); + } + } catch { + // Feld nicht gefunden - überspringen + } + } + + // Rufnummern-Overflow: Extra-Seite wenn nötig + const maxPhoneFields = template.maxPhoneFields || 8; + const overflowNumbers = phoneNumbers.slice(maxPhoneFields); + + if (overflowNumbers.length > 0) { + // Neue Seite für zusätzliche Rufnummern + const page = pdfDoc.addPage(); + const { width, height } = page.getSize(); + const font = await pdfDoc.embedFont('Helvetica' as any); + const boldFont = await pdfDoc.embedFont('Helvetica-Bold' as any); + + let y = height - 50; + page.drawText('Weitere Rufnummern zur Portierung', { x: 50, y, size: 14, font: boldFont }); + y -= 25; + page.drawText(`Kunde: ${contract.customer?.firstName} ${contract.customer?.lastName} (${contract.customer?.customerNumber})`, { x: 50, y, size: 10, font }); + y -= 15; + page.drawText(`Vertrag: ${contract.contractNumber}`, { x: 50, y, size: 10, font }); + y -= 30; + + for (let i = 0; i < overflowNumbers.length; i++) { + page.drawText(`${maxPhoneFields + i + 1}. ${overflowNumbers[i].phoneNumber}`, { x: 50, y, size: 11, font }); + y -= 20; + if (y < 50) { + // Nächste Seite + const newPage = pdfDoc.addPage(); + y = newPage.getSize().height - 50; + } + } + } + + // Nur zugeordnete Felder flatten (nicht editierbar machen) + // Nicht zugeordnete Felder bleiben editierbar zum manuellen Ausfüllen + for (const pdfFieldName of Object.keys(mapping)) { + try { + const field = form.getField(pdfFieldName); + if (field instanceof PDFTextField) { + field.enableReadOnly(); + } + } catch { /* Feld nicht gefunden */ } + } + + return Buffer.from(await pdfDoc.save()); +} diff --git a/backend/todo.md b/backend/todo.md index 8f1446c5..7a850622 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -2,7 +2,7 @@ Vertragliste bei Energie mit Anschlussadresse/Lieferadresse noch in der Liste Bei Mobilfunk die Mobilfunknummer und wenn vorhanden Karteninhaber Bei Festnetz, die Anschlussadresse/Lieferadresse Bei KFZ das Kennzeichen -# +# ende #erledigt Datenschutzerklärung wenn PDF hinterlegt wurde, alle Haken auf Grün setzten. @@ -12,41 +12,42 @@ Aktuell zählt das PDF als Alternative zu den Online-Haken. Du willst es so: PDF hochgeladen → alle 4 Online-Consents automatisch auf GRANTED setzen Kunde entfernt einen Haken im Portal → PDF löschen + Tabs sperren Entsperrung nur durch: alle Haken wieder setzen ODER neues PDF hochladen -# +# ende #erledigt Zweitarif (Gibt es auch 3 Tarifuzähler?) Zähler HT/NT bei Strom Zähler hinzufügen. Auch in die Berechnung die Verbäuche dann darstellen +# ende #erledigt Alle Datumsfelder mit 0 davor wenn es ne einstellige Zahl ist Jetzt : 1.1.2026 Und gewollt 01.01.2026 -# +# ende #erledigt Die Auditmeldungen aussagekräftig -# +# ende Email Log und system testen Sprich senden und Empfnagen -# +# ende Security System testen -# +# ende #erledigt Datenschutzerklärung Website unserer Seite und ein impressum im Kundenportal. Auch wieder über das Einstellungsmenü editirerbar. Bitte mach mir da auch einen Vorschagstext rein -# +# ende Geburtstagskalender, und Geburtgsgruß als Modal beim ersten Login an dem Tag, Sollte der Login bis n7 btage nach Geburtsag sein dann Glückwunsch nachträglich -# +# ende #erledigt Bei der Email datenschutzerklärung erst wenn alle hebel drin sind, auf einen bestätigungsbutton klicken, um sicherzustellen, das alle heben drin sind. @@ -54,18 +55,45 @@ Danch bestätigen, nochmals eine Bestätigiguns emails enden. Denn jetzt kann der Kudne auch nur einen Haken auslassen, das würd uns aber nichts bringen. -# +# ende #erledigt Haben wir bei den Vertragen (also alle) ein Dokumentfeld zum Upload von, Auftragsformular, Lieferbestätigung, Vertragsunterlagen? hier sind wieder png,pdf erlaubt -# +# ende EWE Auftragsformular generieren aus Kundendaten, nur wie bei Neuvertrag. Hinter folgevertrag vielleicht ein Pfeil als Drop down und dann Kann man da Neuer Auftrag EWE, später die Liste erweitern mit Moon usw. was man halt hat aber am anfang EWE. Das wäre dann eine PDF die von der EWE kommt die dann ausgefüllt werden soll. Und wenn es der Erste Vertrag wäre , dann beim Kunden im TAB Verträge, vielleich dann ein auch ein Pfeil nach unten hinter dem Button mit dem Namen Vertrag hinzufügen. Ist die Frage wo legen wir die PDF Vorlagen hin. Vielleicht sogar ein Editor, für Vorlagen wo man dann rein zieht an welcher stelle, welches feld stehen soll aus den kunden daten. +Dann kann man das für weiter Formulare machen die PDF sind. +Moon fachhandle als Beispiel, hat ne API, deshalb kommt das später ;-) Bei Rufnummern wäre das interessant wie man das dann realisiert, weil jede rufnumemr ja ein einzelenen feld ist, aber wir ja vorher nicht wissen wie viele rufnumemrn der kunde hat. allerdings muss man auch das maximum angeben können, denn wenn nur 8 felder sa sind kann man nur 8 rufnummern portieren. Oder es wird eine Extra seite hinten angehangen also erstellt weitere Rufnummern und bei dem Origionaldokument ein Hinweis, weitere Rufnummern siehe letzte Seite, wenn das da erschöpft sein sollte -# \ No newline at end of file +# ende + + +Aus der EMail wenn Vertrag zugeordnet ist, Anhang speichern auch in Vertragsdokumente +Und Rechnungen wie bei den Kündigungsdokumenten +# ende + + +Da steht noch Eigentümer, +Wo können wir das am besten in unser System verpacken? +Denn bei Festnetz und Energieprodukten ist das relevant. +Denn wenn der Kunde zu Miete wohnt ist er nicht Eigentümer. + +Ein Eigentümertümer kann auch eine Firma sein, bei ner Wohnungsbaugesselschft zum beispiel, +DA müssten wir auch wieder name Firma etc. +Eigentlich müssten wir das unter adressen packen. +Vielleicht mit ner Möglichkeit wenn eigentümer nicht ausgefüllt ist, ist der Kudne immer selbst Eigentümer +ABer es könnte ja auch mehrere Objekte mit verschiedenen Eigentümern geben. +Scheiße wie am sinnvollsten lösen +# ende + +Bug auswhl stressfrei email geht nich im Auftragsgenerator +# ende + +Es soll auch zwischen Lieferadresse und Rechungsadresse ausgewählt werden können. als Gruppe. Beudetet wenn eine Feldgruppe aus einer gruppe entweder liefer / Rechnung / oder eigentümer. Dann soll man das auswählen können +# ende \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c980bda7..66aadd4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import PrivacyPolicyEditor from './pages/settings/PrivacyPolicyEditor'; import PortalPrivacy from './pages/portal/PortalPrivacy'; import AuthorizationTemplateEditor from './pages/settings/AuthorizationTemplateEditor'; import ImprintEditor from './pages/settings/ImprintEditor'; +import PdfTemplates from './pages/settings/PdfTemplates'; import WebsitePrivacyPolicyEditor from './pages/settings/WebsitePrivacyPolicyEditor'; import PortalImprint from './pages/portal/PortalImprint'; import PortalWebsitePrivacy from './pages/portal/PortalWebsitePrivacy'; @@ -196,6 +197,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 3abb6320..600dd2b3 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -126,6 +126,23 @@ export default function Settings() { + +
+
+ +
+
+

+ Auftragsvorlagen + +

+

PDF-Vorlagen für Auftragsformulare hochladen und Felder zuordnen.

+
+
+ )} + {hasPermission('contracts:delete') && ( + ) : ( +
+ + {showDropdown && ( + <> +
setShowDropdown(false)} /> +
+ {templates.map((t) => ( + + ))} +
+ + )} +
+ )} + + {showInputModal && ( + setShowInputModal(null)} + /> + )} + + ); +} + +function GenerateInputModal({ templateId, templateName, contractId, onClose }: { + templateId: number; + templateName: string; + contractId: number; + onClose: () => void; +}) { + const [stressfreiEmailId, setStressfreiEmailId] = useState(''); + const [manualValues, setManualValues] = useState>({}); + const [generating] = useState(false); + + const { data: inputsData, isLoading } = useQuery({ + queryKey: ['pdf-inputs', templateId, contractId], + queryFn: () => pdfTemplateApi.getRequiredInputs(templateId, contractId), + }); + + const inputs = inputsData?.data; + + const handleGenerate = () => { + const token = localStorage.getItem('token'); + const params = new URLSearchParams(); + params.set('token', token || ''); + if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId); + for (const [key, value] of Object.entries(manualValues)) { + if (value) params.set(`manual_${key}`, value); + } + window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?${params}`, '_blank'); + onClose(); + }; + + if (isLoading) return null; + + return ( + +
+ {inputs?.needsStressfreiEmail && ( +
+ + +
+ )} + + {inputs?.manualFields && inputs.manualFields.length > 0 && ( +
+

Freitextfelder

+ {inputs.manualFields.map((f) => ( + setManualValues({ ...manualValues, [f.key]: e.target.value })} + placeholder="Eingabe..." + /> + ))} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index f8368ee9..d98b033f 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -717,6 +717,14 @@ function AddressesTab({

{addr.country}

+ {(addr.ownerFirstName || addr.ownerLastName || addr.ownerCompany) && ( +
+ Eigentümer: + {addr.ownerCompany && {addr.ownerCompany} – } + {addr.ownerFirstName} {addr.ownerLastName} + {addr.ownerPhone && · {addr.ownerPhone}} +
+ )}
))} @@ -2097,6 +2105,16 @@ function AddressModal({ city: address?.city || '', country: address?.country || 'Deutschland', isDefault: address?.isDefault || false, + ownerCompany: address?.ownerCompany || '', + ownerFirstName: address?.ownerFirstName || '', + ownerLastName: address?.ownerLastName || '', + ownerStreet: address?.ownerStreet || '', + ownerHouseNumber: address?.ownerHouseNumber || '', + ownerPostalCode: address?.ownerPostalCode || '', + ownerCity: address?.ownerCity || '', + ownerPhone: address?.ownerPhone || '', + ownerMobile: address?.ownerMobile || '', + ownerEmail: address?.ownerEmail || '', }); const [formData, setFormData] = useState(getInitialFormData); @@ -2106,15 +2124,7 @@ function AddressModal({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); - setFormData({ - type: 'DELIVERY_RESIDENCE', - street: '', - houseNumber: '', - postalCode: '', - city: '', - country: 'Deutschland', - isDefault: false, - }); + setFormData(getInitialFormData()); }, }); @@ -2205,6 +2215,82 @@ function AddressModal({ Als Standard setzen + {/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */} + {formData.type === 'DELIVERY_RESIDENCE' && ( +
+

Eigentümer

+

+ Nur ausfüllen wenn der Kunde nicht selbst Eigentümer ist (z.B. Mietwohnung). +

+
+ setFormData({ ...formData, ownerCompany: e.target.value })} + placeholder="z.B. Wohnungsbaugesellschaft" + /> +
+ setFormData({ ...formData, ownerFirstName: e.target.value })} + /> + setFormData({ ...formData, ownerLastName: e.target.value })} + /> +
+
+
+ setFormData({ ...formData, ownerStreet: e.target.value })} + /> +
+ setFormData({ ...formData, ownerHouseNumber: e.target.value })} + /> +
+
+ setFormData({ ...formData, ownerPostalCode: e.target.value })} + /> +
+ setFormData({ ...formData, ownerCity: e.target.value })} + /> +
+
+
+ setFormData({ ...formData, ownerPhone: e.target.value })} + /> + setFormData({ ...formData, ownerMobile: e.target.value })} + /> + setFormData({ ...formData, ownerEmail: e.target.value })} + type="email" + /> +
+
+
+ )} +
+

Auftragsvorlagen

+ +
+ + +

+ Laden Sie PDF-Formulare hoch (z.B. EWE Auftragsformular) und verknüpfen Sie die Formularfelder mit CRM-Daten. + Beim Erstellen eines Auftrags werden die Felder automatisch mit den Kundendaten befüllt. +

+
+ + {isLoading ? ( +
Laden...
+ ) : templates.length === 0 ? ( + +
+ Noch keine Vorlagen vorhanden. Laden Sie eine PDF-Vorlage hoch. +
+
+ ) : ( +
+ {templates.map((t) => ( + +
+
+ +
+
+

{t.name}

+ {t.providerName && {t.providerName}} + {!t.isActive && Inaktiv} +
+ {t.description &&

{t.description}

} +

+ Datei: {t.originalName} + {t.maxPhoneFields && ` · Max. ${t.maxPhoneFields} Rufnummern`} +

+ {(() => { + const mapping = JSON.parse(t.fieldMapping || '{}'); + const count = Object.keys(mapping).length; + return ( +

+ {count > 0 ? `${count} Felder verknüpft` : 'Noch keine Felder verknüpft'} +

+ ); + })()} +
+
+
+ + + + + + + +
+
+
+ ))} +
+ )} + + {/* Erstellen Modal */} + {showCreateModal && ( + { setShowCreateModal(false); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} /> + )} + + {/* Bearbeiten Modal */} + {editingTemplate && ( + { setEditingTemplate(null); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} /> + )} + + {/* Feld-Mapping Modal */} + {mappingTemplate && ( + { setMappingTemplate(null); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} /> + )} + + {/* Test-Vorschau Modal */} + {testTemplate && ( + setTestTemplate(null)} /> + )} + + ); +} + +// ==================== CREATE MODAL ==================== + +function CreateTemplateModal({ onClose }: { onClose: () => void }) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [providerName, setProviderName] = useState(''); + const [maxPhoneFields, setMaxPhoneFields] = useState('8'); + const [file, setFile] = useState(null); + + const createMutation = useMutation({ + mutationFn: async () => { + const formData = new FormData(); + formData.append('name', name); + if (description) formData.append('description', description); + if (providerName) formData.append('providerName', providerName); + formData.append('maxPhoneFields', maxPhoneFields); + formData.append('template', file!); + return pdfTemplateApi.create(formData); + }, + onSuccess: () => onClose(), + }); + + return ( + +
+ setName(e.target.value)} placeholder="z.B. EWE Auftragsformular" required /> + setProviderName(e.target.value)} placeholder="z.B. EWE" /> + setDescription(e.target.value)} placeholder="Optionale Beschreibung" /> + setMaxPhoneFields(e.target.value)} /> + +
+ + +

+ Die PDF muss Formularfelder enthalten. Diese werden nach dem Hochladen automatisch erkannt. +

+
+ + {createMutation.isError && ( +
+ {createMutation.error instanceof Error ? createMutation.error.message : 'Fehler beim Erstellen'} +
+ )} + +
+ + +
+
+
+ ); +} + +// ==================== EDIT MODAL ==================== + +function EditTemplateModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) { + const [name, setName] = useState(template.name); + const [description, setDescription] = useState(template.description || ''); + const [providerName, setProviderName] = useState(template.providerName || ''); + const [maxPhoneFields, setMaxPhoneFields] = useState(template.maxPhoneFields?.toString() || '8'); + const [isActive, setIsActive] = useState(template.isActive); + + const updateMutation = useMutation({ + mutationFn: () => pdfTemplateApi.update(template.id, { name, description, providerName, maxPhoneFields: parseInt(maxPhoneFields), isActive }), + onSuccess: () => onClose(), + }); + + return ( + +
+ setName(e.target.value)} required /> + setProviderName(e.target.value)} /> + setDescription(e.target.value)} /> + setMaxPhoneFields(e.target.value)} /> + +
+ + +
+
+
+ ); +} + +// ==================== FIELD MAPPING MODAL ==================== + +function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) { + const [mapping, setMapping] = useState>(JSON.parse(template.fieldMapping || '{}')); + + const { data: fieldsData } = useQuery({ + queryKey: ['pdf-fields', template.id], + queryFn: () => pdfTemplateApi.getFields(template.id), + }); + + const { data: crmFieldsData } = useQuery({ + queryKey: ['crm-fields', template.maxPhoneFields], + queryFn: () => pdfTemplateApi.getCrmFields(template.maxPhoneFields || 8), + }); + + const saveMutation = useMutation({ + mutationFn: () => pdfTemplateApi.update(template.id, { fieldMapping: mapping }), + onSuccess: () => onClose(), + }); + + const pdfFields = fieldsData?.data || []; + const totalPages = (fieldsData as any)?.totalPages || 1; + const crmFields = crmFieldsData?.data || []; + + // CRM-Felder nach Gruppe + const crmGroups = crmFields.reduce((acc, f) => { + if (!acc[f.group]) acc[f.group] = []; + acc[f.group].push(f); + return acc; + }, {} as Record); + + const [highlightedField, setHighlightedField] = useState(null); + + return ( +
+ {/* PDF-Vorschau links (nur zur Ansicht, nicht interaktiv) */} +
+
+ PDF-Vorschau mit Feldnamen + [Feldname] zeigt wo das Feld in der PDF liegt +
+