Email-Anhänge als Vertragsdokumente + Rechnungen für alle Vertragstypen
Der SaveAttachmentModal hat jetzt drei Modi (wenn E-Mail einem Vertrag zugeordnet ist): 1. Als Dokument – in feste Slots (Kündigungsschreiben etc.), unverändert 2. Als Vertragsdokument – NEU: flexible ContractDocument-Tabelle mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + optionalen Notizen 3. Als Rechnung – jetzt für ALLE Vertragstypen (vorher nur Strom/Gas) Backend: - Neuer Endpoint POST /api/emails/:id/attachments/:filename/save-as-contract-document - saveAttachmentAsInvoice + saveEmailAsInvoice: ELECTRICITY/GAS-Einschränkung entfernt, nutzt jetzt addInvoiceByContract als Fallback für Nicht-Energie-Verträge Frontend: - cachedEmailApi.saveAttachmentAsContractDocument hinzugefügt - SaveAttachmentModal: neuer Mode 'contractDocument' mit Typ+Notizen - Mode-Toggle zeigt jetzt alle drei Optionen wenn Vertrag zugeordnet Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1579,7 +1579,7 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||
// Vertrag laden
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
include: { energyDetails: true },
|
||||
@@ -1593,22 +1593,6 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Nur für Strom- und Gas-Verträge verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contract.energyDetails) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Empfänger-Adressen parsen (JSON Array)
|
||||
let toAddresses: string[] = [];
|
||||
let ccAddresses: string[] = [];
|
||||
@@ -1645,13 +1629,20 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
||||
// PDF speichern
|
||||
fs.writeFileSync(filePath, pdfBuffer);
|
||||
|
||||
// Invoice in DB erstellen
|
||||
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
// Invoice in DB erstellen (für alle Vertragstypen)
|
||||
const invoice = contract.energyDetails
|
||||
? await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
: await invoiceService.addInvoiceByContract(contract.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1715,7 +1706,7 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||
// Vertrag laden
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
include: { energyDetails: true },
|
||||
@@ -1729,22 +1720,6 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Nur für Strom- und Gas-Verträge verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contract.energyDetails) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||
if (email.folder === 'SENT' && email.uid === 0) {
|
||||
res.status(400).json({
|
||||
@@ -1816,13 +1791,20 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
// Datei speichern
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
// Invoice in DB erstellen
|
||||
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
// Invoice in DB erstellen (für alle Vertragstypen)
|
||||
const invoice = contract.energyDetails
|
||||
? await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
: await invoiceService.addInvoiceByContract(contract.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1837,3 +1819,132 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
||||
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
||||
*/
|
||||
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!documentType || typeof documentType !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'documentType ist erforderlich',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.contractId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'E-Mail ist keinem Vertrag zugeordnet',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
select: { id: true, contractNumber: true, customerId: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||
if (email.folder === 'SENT' && email.uid === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// StressfreiEmail für IMAP-Zugangsdaten
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
|
||||
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Mailbox-Zugangsdaten verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await getImapSmtpSettings();
|
||||
if (!settings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||
|
||||
const credentials: ImapCredentials = {
|
||||
host: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.imapEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
if (!attachment) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uploads-Verzeichnis
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const ext = path.extname(filename) || '.pdf';
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
const doc = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType,
|
||||
documentPath: relativePath,
|
||||
originalName: filename,
|
||||
notes: notes || null,
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsContractDocument error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,15 @@ router.post(
|
||||
cachedEmailController.saveAttachmentAsInvoice
|
||||
);
|
||||
|
||||
// Anhang als Vertragsdokument speichern
|
||||
// POST /api/emails/:id/attachments/:filename/save-as-contract-document { documentType, notes? }
|
||||
router.post(
|
||||
'/emails/:id/attachments/:filename/save-as-contract-document',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
cachedEmailController.saveAttachmentAsContractDocument
|
||||
);
|
||||
|
||||
// ==================== VERTRAGSZUORDNUNG ====================
|
||||
|
||||
// E-Mail Vertrag zuordnen
|
||||
|
||||
+8
-5
@@ -10,11 +10,6 @@
|
||||
|
||||
### Security System testen
|
||||
|
||||
### Email → Vertragsdokumente
|
||||
Wenn eine Email einem Vertrag zugeordnet ist:
|
||||
- Anhänge auch in Vertragsdokumente speichern
|
||||
- Rechnungen wie Kündigungsdokumente behandeln
|
||||
|
||||
### Factory-Defaults: Export + Import von Lieferanten & Formularvorlagen
|
||||
**Ziel:** Einmal gepflegte Stammdaten (Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
||||
PDF-Auftragsvorlagen) sollen sich exportieren und in andere Installationen oder
|
||||
@@ -57,6 +52,14 @@ als Factory-Default beim Initialisieren wieder einspielen lassen.
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
|
||||
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
|
||||
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) – wie bisher
|
||||
2. **Als Vertragsdokument** – neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
|
||||
3. **Als Rechnung** – jetzt für **alle** Vertragstypen (vorher nur Strom/Gas)
|
||||
- Gleiches gilt für das Speichern der gesamten Email als PDF-Rechnung
|
||||
- Neuer Backend-Endpoint `saveAttachmentAsContractDocument` für die flexible ContractDocument-Tabelle
|
||||
|
||||
- [x] **Geburtstag-Management-Modal in Kundenstammdaten**
|
||||
- Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
|
||||
- **Gruß zurücksetzen:** setzt `lastBirthdayGreetingYear` auf null zurück (fürs Debugging + Fallback)
|
||||
|
||||
Reference in New Issue
Block a user