PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung

- 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) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-05 19:16:47 +02:00
parent 9fa1cbc591
commit 9a84e2d3cb
16 changed files with 1881 additions and 21 deletions

View File

@ -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",

View File

@ -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"
},

View File

@ -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())

View File

@ -202,6 +202,11 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
const fieldLabels: Record<string, string> = {
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;

View File

@ -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<string, string> = 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',
});
}
}

View File

@ -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) => {

View File

@ -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;

View File

@ -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<Buffer> {
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<string, string> = 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<string, string>;
}
): Promise<Buffer> {
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<string, string> = { ELECTRICITY: 'Strom', GAS: 'Gas', DSL: 'DSL', CABLE: 'Kabelinternet', FIBER: 'Glasfaser', MOBILE: 'Mobilfunk', TV: 'TV', CAR_INSURANCE: 'KFZ-Versicherung' };
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
const addr = contract.address;
const bAddr = contract.billingAddress;
const dataContext: Record<string, string> = {
// 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<string, string> = 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());
}

View File

@ -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
#
# 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

View File

@ -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() {
<Route path="settings/gdpr" element={<GDPRDashboard />} />
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
<Route path="settings/pdf-templates" element={<PdfTemplates />} />
<Route path="settings/imprint" element={<ImprintEditor />} />
<Route path="settings/website-privacy-policy" element={<WebsitePrivacyPolicyEditor />} />

View File

@ -126,6 +126,23 @@ export default function Settings() {
</div>
</div>
</Link>
<Link
to="/settings/pdf-templates"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<FileText className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Auftragsvorlagen
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">PDF-Vorlagen für Auftragsformulare hochladen und Felder zuordnen.</p>
</div>
</div>
</Link>
<Link
to="/settings/email-providers"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
@ -1772,6 +1772,7 @@ export default function ContractDetail() {
</Button>
</Link>
)}
<GenerateOrderButton contractId={contractId} />
{hasPermission('contracts:delete') && (
<Button
variant="danger"
@ -3269,3 +3270,163 @@ function ContractDocumentsSection({
</Card>
);
}
// ==================== AUFTRAG GENERIEREN BUTTON ====================
function GenerateOrderButton({ contractId }: { contractId: number }) {
const [showDropdown, setShowDropdown] = useState(false);
const [showInputModal, setShowInputModal] = useState<{ templateId: number; templateName: string } | null>(null);
const { data: templatesData } = useQuery({
queryKey: ['pdf-templates'],
queryFn: () => pdfTemplateApi.getAll(),
});
const templates = (templatesData?.data || []).filter(t => t.isActive);
if (templates.length === 0) return null;
const handleSelect = async (templateId: number, templateName: string) => {
setShowDropdown(false);
// Prüfen ob manuelle Inputs nötig sind
try {
const result = await pdfTemplateApi.getRequiredInputs(templateId, contractId);
const inputs = result.data;
if (inputs && (inputs.needsStressfreiEmail || inputs.manualFields.length > 0)) {
setShowInputModal({ templateId, templateName });
} else {
// Direkt generieren (GET-Link)
const token = localStorage.getItem('token');
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank');
}
} catch {
// Fallback: direkt generieren
const token = localStorage.getItem('token');
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank');
}
};
return (
<>
{templates.length === 1 ? (
<Button variant="secondary" onClick={() => handleSelect(templates[0].id, templates[0].name)}>
<FileText className="w-4 h-4 mr-2" />
{templates[0].name}
</Button>
) : (
<div className="relative">
<Button variant="secondary" onClick={() => setShowDropdown(!showDropdown)}>
<FileText className="w-4 h-4 mr-2" />
Auftrag generieren
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowDropdown(false)} />
<div className="absolute right-0 mt-1 w-64 bg-white border rounded-lg shadow-lg z-20 py-1">
{templates.map((t) => (
<button
key={t.id}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center gap-2"
onClick={() => handleSelect(t.id, t.name)}
>
<FileText className="w-4 h-4 text-blue-500" />
<div>
<span className="font-medium">{t.name}</span>
{t.providerName && <span className="text-xs text-gray-500 ml-1">({t.providerName})</span>}
</div>
</button>
))}
</div>
</>
)}
</div>
)}
{showInputModal && (
<GenerateInputModal
templateId={showInputModal.templateId}
templateName={showInputModal.templateName}
contractId={contractId}
onClose={() => setShowInputModal(null)}
/>
)}
</>
);
}
function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
templateId: number;
templateName: string;
contractId: number;
onClose: () => void;
}) {
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
const [manualValues, setManualValues] = useState<Record<string, string>>({});
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 (
<Modal isOpen={true} onClose={onClose} title={`${templateName} Zusätzliche Angaben`}>
<div className="space-y-4">
{inputs?.needsStressfreiEmail && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Stressfrei-Wechseln E-Mail</label>
<select
value={stressfreiEmailId}
onChange={(e) => setStressfreiEmailId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Bitte wählen...</option>
{inputs.stressfreiEmails.map((e) => (
<option key={e.id} value={e.id}>{e.email}</option>
))}
</select>
</div>
)}
{inputs?.manualFields && inputs.manualFields.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">Freitextfelder</p>
{inputs.manualFields.map((f) => (
<Input
key={f.key}
label={f.pdfFieldName}
value={manualValues[f.key] || ''}
onChange={(e) => setManualValues({ ...manualValues, [f.key]: e.target.value })}
placeholder="Eingabe..."
/>
))}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={handleGenerate} disabled={generating}>
<FileText className="w-4 h-4 mr-2" />
{generating ? 'Generiere...' : 'PDF generieren'}
</Button>
</div>
</div>
</Modal>
);
}

View File

@ -717,6 +717,14 @@ function AddressesTab({
</p>
<p className="text-gray-500">{addr.country}</p>
</CopyableBlock>
{(addr.ownerFirstName || addr.ownerLastName || addr.ownerCompany) && (
<div className="mt-2 pt-2 border-t text-xs text-gray-500">
<span className="font-medium">Eigentümer: </span>
{addr.ownerCompany && <span>{addr.ownerCompany} </span>}
{addr.ownerFirstName} {addr.ownerLastName}
{addr.ownerPhone && <span> · {addr.ownerPhone}</span>}
</div>
)}
</div>
))}
</div>
@ -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
</label>
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
{formData.type === 'DELIVERY_RESIDENCE' && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-1">Eigentümer</h4>
<p className="text-xs text-gray-500 mb-3">
Nur ausfüllen wenn der Kunde nicht selbst Eigentümer ist (z.B. Mietwohnung).
</p>
<div className="space-y-3">
<Input
label="Firma (optional)"
value={formData.ownerCompany}
onChange={(e) => setFormData({ ...formData, ownerCompany: e.target.value })}
placeholder="z.B. Wohnungsbaugesellschaft"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Vorname"
value={formData.ownerFirstName}
onChange={(e) => setFormData({ ...formData, ownerFirstName: e.target.value })}
/>
<Input
label="Nachname"
value={formData.ownerLastName}
onChange={(e) => setFormData({ ...formData, ownerLastName: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Input
label="Straße"
value={formData.ownerStreet}
onChange={(e) => setFormData({ ...formData, ownerStreet: e.target.value })}
/>
</div>
<Input
label="Hausnr."
value={formData.ownerHouseNumber}
onChange={(e) => setFormData({ ...formData, ownerHouseNumber: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<Input
label="PLZ"
value={formData.ownerPostalCode}
onChange={(e) => setFormData({ ...formData, ownerPostalCode: e.target.value })}
/>
<div className="col-span-2">
<Input
label="Ort"
value={formData.ownerCity}
onChange={(e) => setFormData({ ...formData, ownerCity: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<Input
label="Telefon"
value={formData.ownerPhone}
onChange={(e) => setFormData({ ...formData, ownerPhone: e.target.value })}
/>
<Input
label="Mobil"
value={formData.ownerMobile}
onChange={(e) => setFormData({ ...formData, ownerMobile: e.target.value })}
/>
<Input
label="E-Mail"
value={formData.ownerEmail}
onChange={(e) => setFormData({ ...formData, ownerEmail: e.target.value })}
type="email"
/>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen

View File

@ -0,0 +1,498 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { pdfTemplateApi, contractApi } from '../../services/api';
import type { PdfTemplate, CrmField, Contract } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal';
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
export default function PdfTemplates() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<PdfTemplate | null>(null);
const [mappingTemplate, setMappingTemplate] = useState<PdfTemplate | null>(null);
const [testTemplate, setTestTemplate] = useState<PdfTemplate | null>(null);
const { data: templatesData, isLoading } = useQuery({
queryKey: ['pdf-templates'],
queryFn: () => pdfTemplateApi.getAll(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => pdfTemplateApi.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }),
});
const templates = templatesData?.data || [];
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold flex-1">Auftragsvorlagen</h1>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Vorlage hochladen
</Button>
</div>
<Card className="mb-6">
<p className="text-sm text-gray-600">
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.
</p>
</Card>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : templates.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
Noch keine Vorlagen vorhanden. Laden Sie eine PDF-Vorlage hoch.
</div>
</Card>
) : (
<div className="space-y-4">
{templates.map((t) => (
<Card key={t.id}>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-blue-500 mt-0.5" />
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold">{t.name}</h3>
{t.providerName && <Badge variant="info">{t.providerName}</Badge>}
{!t.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{t.description && <p className="text-sm text-gray-500 mt-1">{t.description}</p>}
<p className="text-xs text-gray-400 mt-1">
Datei: {t.originalName}
{t.maxPhoneFields && ` · Max. ${t.maxPhoneFields} Rufnummern`}
</p>
{(() => {
const mapping = JSON.parse(t.fieldMapping || '{}');
const count = Object.keys(mapping).length;
return (
<p className="text-xs text-gray-400">
{count > 0 ? `${count} Felder verknüpft` : 'Noch keine Felder verknüpft'}
</p>
);
})()}
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setMappingTemplate(t)}>
<Link2 className="w-4 h-4 mr-1" />
Felder zuordnen
</Button>
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
<Play className="w-4 h-4 text-green-500" />
</Button>
<a href={`/api${t.templatePath}`} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
<Eye className="w-4 h-4" />
</Button>
</a>
<Button variant="ghost" size="sm" onClick={() => setEditingTemplate(t)} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => {
if (confirm(`Vorlage "${t.name}" wirklich löschen?`)) deleteMutation.mutate(t.id);
}} title="Löschen">
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Erstellen Modal */}
{showCreateModal && (
<CreateTemplateModal onClose={() => { setShowCreateModal(false); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} />
)}
{/* Bearbeiten Modal */}
{editingTemplate && (
<EditTemplateModal template={editingTemplate} onClose={() => { setEditingTemplate(null); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} />
)}
{/* Feld-Mapping Modal */}
{mappingTemplate && (
<FieldMappingModal template={mappingTemplate} onClose={() => { setMappingTemplate(null); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} />
)}
{/* Test-Vorschau Modal */}
{testTemplate && (
<TestPreviewModal template={testTemplate} onClose={() => setTestTemplate(null)} />
)}
</div>
);
}
// ==================== 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<File | null>(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 (
<Modal isOpen={true} onClose={onClose} title="Neue Auftragsvorlage">
<div className="space-y-4">
<Input label="Name *" value={name} onChange={(e) => setName(e.target.value)} placeholder="z.B. EWE Auftragsformular" required />
<Input label="Anbieter" value={providerName} onChange={(e) => setProviderName(e.target.value)} placeholder="z.B. EWE" />
<Input label="Beschreibung" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optionale Beschreibung" />
<Input label="Max. Rufnummern-Felder" type="number" value={maxPhoneFields} onChange={(e) => setMaxPhoneFields(e.target.value)} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PDF-Vorlage *</label>
<label className="flex items-center justify-center gap-2 p-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-gray-50">
<Upload className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-600">{file ? file.name : 'PDF-Datei auswählen'}</span>
<input type="file" accept=".pdf" className="hidden" onChange={(e) => setFile(e.target.files?.[0] || null)} />
</label>
<p className="text-xs text-gray-500 mt-1">
Die PDF muss Formularfelder enthalten. Diese werden nach dem Hochladen automatisch erkannt.
</p>
</div>
{createMutation.isError && (
<div className="p-3 bg-red-50 text-red-700 text-sm rounded-lg">
{createMutation.error instanceof Error ? createMutation.error.message : 'Fehler beim Erstellen'}
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => createMutation.mutate()} disabled={!name || !file || createMutation.isPending}>
{createMutation.isPending ? 'Hochladen...' : 'Hochladen'}
</Button>
</div>
</div>
</Modal>
);
}
// ==================== 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 (
<Modal isOpen={true} onClose={onClose} title="Vorlage bearbeiten">
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<Input label="Anbieter" value={providerName} onChange={(e) => setProviderName(e.target.value)} />
<Input label="Beschreibung" value={description} onChange={(e) => setDescription(e.target.value)} />
<Input label="Max. Rufnummern-Felder" type="number" value={maxPhoneFields} onChange={(e) => setMaxPhoneFields(e.target.value)} />
<label className="flex items-center gap-2">
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} className="rounded" />
<span className="text-sm">Aktiv</span>
</label>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => updateMutation.mutate()} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
</Modal>
);
}
// ==================== FIELD MAPPING MODAL ====================
function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) {
const [mapping, setMapping] = useState<Record<string, string>>(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<string, CrmField[]>);
const [highlightedField, setHighlightedField] = useState<string | null>(null);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
{/* PDF-Vorschau links (nur zur Ansicht, nicht interaktiv) */}
<div className="w-1/2 bg-gray-800 flex flex-col">
<div className="p-3 bg-gray-900 text-white text-sm flex items-center justify-between">
<span>PDF-Vorschau mit Feldnamen</span>
<span className="text-gray-400 text-xs">[Feldname] zeigt wo das Feld in der PDF liegt</span>
</div>
<iframe
src={`/api/pdf-templates/${template.id}/preview?token=${localStorage.getItem('token')}`}
className="flex-1 w-full bg-white"
title="PDF Vorschau mit Feldnamen"
/>
</div>
{/* Zuordnung rechts */}
<div className="w-1/2 bg-white flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Felder zuordnen</h2>
<p className="text-xs text-gray-500">
PDF-Feld links in der Vorschau finden, dann rechts das CRM-Feld zuordnen
</p>
</div>
<Badge>{Object.keys(mapping).length} / {pdfFields.length}</Badge>
</div>
<div className="flex-1 overflow-y-auto p-4">
{pdfFields.length === 0 ? (
<div className="text-center py-8 text-amber-600 bg-amber-50 rounded-lg">
<p className="font-medium">Keine Formularfelder gefunden.</p>
<p className="text-sm mt-1">Die PDF muss interaktive Formularfelder enthalten.</p>
</div>
) : (
<>
<div className="flex gap-2 mb-3">
<button
onClick={() => setHighlightedField('all')}
className={`px-3 py-1 rounded text-xs font-medium ${highlightedField === 'all' || !highlightedField ? 'bg-gray-200 text-gray-800' : 'text-gray-500 hover:bg-gray-100'}`}
>
Alle ({pdfFields.length})
</button>
<button
onClick={() => setHighlightedField('open')}
className={`px-3 py-1 rounded text-xs font-medium ${highlightedField === 'open' ? 'bg-amber-100 text-amber-800' : 'text-gray-500 hover:bg-gray-100'}`}
>
Offen ({pdfFields.filter(f => !mapping[f.name]).length})
</button>
<button
onClick={() => setHighlightedField('done')}
className={`px-3 py-1 rounded text-xs font-medium ${highlightedField === 'done' ? 'bg-green-100 text-green-800' : 'text-gray-500 hover:bg-gray-100'}`}
>
Zugeordnet ({pdfFields.filter(f => !!mapping[f.name]).length})
</button>
</div>
<div className="space-y-2">
{(() => {
const filtered = pdfFields.filter((f: any) => {
if (highlightedField === 'open') return !mapping[f.name];
if (highlightedField === 'done') return !!mapping[f.name];
return true;
});
let lastPage = -1;
let fieldNum = 0;
return filtered.map((pdfField: any) => {
fieldNum++;
const isAssigned = !!mapping[pdfField.name];
const assignedLabel = isAssigned
? crmFields.find(c => c.path === mapping[pdfField.name])?.label
: null;
const showPageHeader = totalPages > 1 && pdfField.page !== lastPage;
lastPage = pdfField.page;
return (
<div key={pdfField.name}>
{showPageHeader && (
<div className="flex items-center gap-2 py-2 mt-2 first:mt-0">
<div className="flex-1 border-t border-blue-200" />
<span className="text-xs font-medium text-blue-600 px-2 bg-blue-50 rounded-full">
Seite {pdfField.page + 1}
</span>
<div className="flex-1 border-t border-blue-200" />
</div>
)}
<div
className={`p-3 rounded-lg border transition-colors ${
isAssigned ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-400 w-5">{fieldNum}.</span>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isAssigned ? 'bg-green-500' : 'bg-amber-400'}`} />
<span className="font-mono text-sm font-medium truncate" title={pdfField.name}>{pdfField.name}</span>
<Badge variant="default">{pdfField.type}</Badge>
{assignedLabel && (
<span className="text-xs text-green-700 ml-auto flex-shrink-0"> {assignedLabel}</span>
)}
</div>
<select
value={mapping[pdfField.name] || ''}
onChange={(e) => {
const newMapping = { ...mapping };
if (e.target.value) newMapping[pdfField.name] = e.target.value;
else delete newMapping[pdfField.name];
setMapping(newMapping);
}}
className={`block w-full px-3 py-1.5 border rounded text-sm ${
isAssigned ? 'border-green-300 bg-white' : 'border-gray-300'
}`}
>
<option value="">-- Nicht zuordnen --</option>
{Object.entries(crmGroups).map(([group, fields]) => (
<optgroup key={group} label={group}>
{fields.map((f) => (
<option key={f.path} value={f.path}>{f.label}</option>
))}
</optgroup>
))}
</select>
</div>
</div>
);
});
})()}
</div>
</>
)}
</div>
<div className="p-4 border-t flex justify-between items-center">
<p className="text-xs text-gray-500">
{Object.keys(mapping).length} von {pdfFields.length} zugeordnet
</p>
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
</div>
</div>
);
}
// ==================== TEST PREVIEW MODAL ====================
function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) {
const [search, setSearch] = useState('');
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const { data: contractsData, isLoading } = useQuery({
queryKey: ['contracts-search', search],
queryFn: () => contractApi.getAll({ search: search || undefined, limit: 10 }),
enabled: search.length >= 2,
});
const contracts: Contract[] = contractsData?.data || [];
const token = localStorage.getItem('token');
const handleGenerate = () => {
if (!selectedContractId) return;
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${token}`;
window.open(url, '_blank');
};
return (
<Modal isOpen={true} onClose={onClose} title={`Testvorschau: ${template.name}`}>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Wählen Sie einen Vertrag aus, um die Vorlage mit echten Daten zu testen.
</p>
<Input
label="Vertrag suchen (Vertragsnr., Kunde, Kundennr.)"
value={search}
onChange={(e) => { setSearch(e.target.value); setSelectedContractId(null); }}
placeholder="z.B. ELE-ML9U66 oder Robbers"
/>
{isLoading && <p className="text-sm text-gray-500">Suche...</p>}
{contracts.length > 0 && (
<div className="max-h-48 overflow-y-auto border rounded-lg">
{contracts.map((c) => (
<button
key={c.id}
onClick={() => setSelectedContractId(c.id)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center justify-between border-b last:border-0 ${
selectedContractId === c.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div>
<span className="font-mono font-medium">{c.contractNumber}</span>
{c.customer && (
<span className="text-gray-500 ml-2">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</span>
)}
</div>
<Badge variant="default">{c.type}</Badge>
</button>
))}
</div>
)}
{search.length >= 2 && !isLoading && contracts.length === 0 && (
<p className="text-sm text-gray-500">Keine Verträge gefunden.</p>
)}
{selectedContractId && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
Vertrag ausgewählt. Klicken Sie auf "PDF generieren" um die befüllte Vorlage zu öffnen.
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>Schließen</Button>
<Button onClick={handleGenerate} disabled={!selectedContractId}>
<Play className="w-4 h-4 mr-2" />
PDF generieren
</Button>
</div>
</div>
</Modal>
);
}

View File

@ -1307,6 +1307,55 @@ export const auditLogApi = {
},
};
// ==================== PDF TEMPLATES ====================
export const pdfTemplateApi = {
getAll: async () => {
const res = await api.get<ApiResponse<import('../types').PdfTemplate[]>>('/pdf-templates');
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<import('../types').PdfTemplate>>(`/pdf-templates/${id}`);
return res.data;
},
create: async (formData: FormData) => {
const res = await api.post<ApiResponse<import('../types').PdfTemplate & { pdfFields?: import('../types').PdfField[] }>>('/pdf-templates', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
update: async (id: number, data: Record<string, unknown>) => {
const res = await api.put<ApiResponse<import('../types').PdfTemplate>>(`/pdf-templates/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/pdf-templates/${id}`);
return res.data;
},
getFields: async (id: number) => {
const res = await api.get<ApiResponse<import('../types').PdfField[]>>(`/pdf-templates/${id}/fields`);
return res.data;
},
getCrmFields: async (maxPhoneFields?: number) => {
const res = await api.get<ApiResponse<import('../types').CrmField[]>>('/pdf-templates/crm-fields', { params: { maxPhoneFields } });
return res.data;
},
getRequiredInputs: async (templateId: number, contractId: number) => {
const res = await api.get<ApiResponse<{
needsStressfreiEmail: boolean;
stressfreiEmails: { id: number; email: string }[];
manualFields: { key: string; pdfFieldName: string }[];
}>>(`/pdf-templates/${templateId}/generate/${contractId}/inputs`);
return res.data;
},
generatePdf: async (templateId: number, contractId: number, extras?: { stressfreiEmailId?: number; manualValues?: Record<string, string> }) => {
const res = await api.post(`/pdf-templates/${templateId}/generate/${contractId}`, extras || {}, { responseType: 'blob' });
return res.data;
},
generateUrl: (templateId: number, contractId: number) =>
`/api/pdf-templates/${templateId}/generate/${contractId}`,
};
// ==================== EMAIL LOG ====================
export interface EmailLog {

View File

@ -123,6 +123,17 @@ export interface Address {
city: string;
country: string;
isDefault: boolean;
// 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;
}
export interface BankCard {
@ -163,6 +174,34 @@ export interface ContractDocument {
createdAt: string;
}
export interface PdfTemplate {
id: number;
name: string;
description?: string;
providerName?: string;
templatePath: string;
originalName: string;
fieldMapping: string;
phoneFieldPrefix?: string;
maxPhoneFields?: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PdfField {
name: string;
type: string;
page: number;
y: number;
}
export interface CrmField {
path: string;
label: string;
group: string;
}
export type MeterTariffModel = 'SINGLE' | 'DUAL';
export interface Meter {