Compare commits

...

2 Commits

Author SHA1 Message Date
duffyduck 9d5412cef0 Fix: Anrede per Du/Sie wird nicht gespeichert
Das useInformalAddress-Feld war:
1. Im Frontend-Submit-Handler nicht in submitData enthalten (wurde bei jedem Update rausgefiltert)
2. Im Service-Type nicht definiert (TypeScript-mäßig unbekannt)
3. Beim Laden im Edit-Mode: Boolean aus DB matchte nicht das String-value des <select>

Fixes:
- Frontend: submitData enthält jetzt useInformalAddress (String oder Boolean → sauberes Boolean)
- Frontend: beim reset() wird Boolean zu 'true'/'false' konvertiert für <select>
- Backend: Service-Type erweitert um useInformalAddress, autoBirthdayGreeting, autoBirthdayChannel
- Backend: Audit-Feldlabels für die neuen Felder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:10:03 +02:00
duffyduck 958752ecc9 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>
2026-04-23 13:06:10 +02:00
8 changed files with 322 additions and 66 deletions
+159 -48
View File
@@ -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);
}
}
@@ -88,6 +88,9 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail',
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen',
useInformalAddress: 'Anrede per',
autoBirthdayGreeting: 'Autom. Geburtstagsgruß',
autoBirthdayChannel: 'Kanal für Geburtstagsgruß',
};
for (const [key, value] of Object.entries(data)) {
// Technische/interne Felder überspringen
+9
View File
@@ -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
+3
View File
@@ -136,6 +136,7 @@ export async function updateCustomer(
data: {
type?: CustomerType;
salutation?: string;
useInformalAddress?: boolean;
firstName?: string;
lastName?: string;
companyName?: string;
@@ -148,6 +149,8 @@ export async function updateCustomer(
businessRegistration?: string;
commercialRegister?: string;
notes?: string;
autoBirthdayGreeting?: boolean;
autoBirthdayChannel?: string | null;
}
) {
return prisma.customer.update({
+8 -5
View File
@@ -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)
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt, FolderOpen } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import Input from '../ui/Input';
@@ -9,6 +9,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import type { InvoiceType } from '../../types';
const CONTRACT_DOCUMENT_TYPES = [
'Auftragsformular',
'Auftragsbestätigung',
'Lieferbestätigung',
'Vertragsunterlagen',
'Vollmacht',
'Widerrufsbelehrung',
'Preisblatt',
'Sonstiges',
];
interface SaveAttachmentModalProps {
isOpen: boolean;
onClose: () => void;
@@ -25,7 +36,7 @@ type SelectedTarget = {
label: string;
};
type SaveMode = 'document' | 'invoice';
type SaveMode = 'document' | 'invoice' | 'contractDocument';
export default function SaveAttachmentModal({
isOpen,
@@ -42,6 +53,10 @@ export default function SaveAttachmentModal({
invoiceType: 'INTERIM' as InvoiceType,
notes: '',
});
const [contractDocumentData, setContractDocumentData] = useState({
documentType: CONTRACT_DOCUMENT_TYPES[0],
notes: '',
});
const queryClient = useQueryClient();
// Ziele laden
@@ -53,8 +68,8 @@ export default function SaveAttachmentModal({
const targets = targetsData?.data;
// Prüfen ob es ein Energievertrag ist
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
// Vertrag zugeordnet? → dann Rechnung + Vertragsdokument möglich
const hasContract = !!targets?.contract;
const saveMutation = useMutation({
mutationFn: () => {
@@ -113,6 +128,31 @@ export default function SaveAttachmentModal({
},
});
const saveContractDocumentMutation = useMutation({
mutationFn: () => {
return cachedEmailApi.saveAttachmentAsContractDocument(emailId, attachmentFilename, {
documentType: contractDocumentData.documentType,
notes: contractDocumentData.notes || undefined,
});
},
onSuccess: () => {
toast.success('Anhang als Vertragsdokument gespeichert');
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
queryClient.invalidateQueries({ queryKey: ['contracts'] });
if (targets?.contract?.id) {
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract-documents', targets.contract.id] });
}
onSuccess?.();
handleClose();
},
onError: (error: Error) => {
toast.error(error.message || 'Fehler beim Speichern des Vertragsdokuments');
},
});
const handleClose = () => {
setSelectedTarget(null);
setSaveMode('document');
@@ -121,6 +161,10 @@ export default function SaveAttachmentModal({
invoiceType: 'INTERIM',
notes: '',
});
setContractDocumentData({
documentType: CONTRACT_DOCUMENT_TYPES[0],
notes: '',
});
onClose();
};
@@ -263,12 +307,12 @@ export default function SaveAttachmentModal({
{targets && (
<>
{/* Mode Toggle für Energieverträge */}
{isEnergyContract && (
{/* Mode Toggle (nur wenn ein Vertrag zugeordnet ist) */}
{hasContract && (
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<button
onClick={() => setSaveMode('document')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
saveMode === 'document'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
@@ -277,9 +321,20 @@ export default function SaveAttachmentModal({
<FileText className="w-4 h-4" />
Als Dokument
</button>
<button
onClick={() => setSaveMode('contractDocument')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
saveMode === 'contractDocument'
? 'bg-white text-orange-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<FolderOpen className="w-4 h-4" />
Vertragsdokument
</button>
<button
onClick={() => setSaveMode('invoice')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
saveMode === 'invoice'
? 'bg-white text-green-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
@@ -343,7 +398,7 @@ export default function SaveAttachmentModal({
)}
{/* Invoice Mode */}
{saveMode === 'invoice' && isEnergyContract && (
{saveMode === 'invoice' && hasContract && (
<div className="space-y-4">
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-700">
@@ -377,6 +432,35 @@ export default function SaveAttachmentModal({
/>
</div>
)}
{/* Contract Document Mode */}
{saveMode === 'contractDocument' && hasContract && (
<div className="space-y-4">
<div className="p-3 bg-orange-50 rounded-lg">
<p className="text-sm text-orange-700">
Der Anhang wird als Vertragsdokument für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
</p>
</div>
<Select
label="Dokumenttyp"
value={contractDocumentData.documentType}
onChange={(e) =>
setContractDocumentData({ ...contractDocumentData, documentType: e.target.value })
}
options={CONTRACT_DOCUMENT_TYPES.map((t) => ({ value: t, label: t }))}
/>
<Input
label="Notizen (optional)"
value={contractDocumentData.notes}
onChange={(e) =>
setContractDocumentData({ ...contractDocumentData, notes: e.target.value })
}
placeholder="Optionale Anmerkungen..."
/>
</div>
)}
</>
)}
@@ -396,21 +480,47 @@ export default function SaveAttachmentModal({
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
{saveMode === 'document' ? (
{saveMode === 'document' && (
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || saveMutation.isPending || saveInvoiceMutation.isPending}
disabled={
!selectedTarget ||
saveMutation.isPending ||
saveInvoiceMutation.isPending ||
saveContractDocumentMutation.isPending
}
>
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</Button>
) : (
)}
{saveMode === 'invoice' && (
<Button
onClick={() => saveInvoiceMutation.mutate()}
disabled={!invoiceData.invoiceDate || saveMutation.isPending || saveInvoiceMutation.isPending}
disabled={
!invoiceData.invoiceDate ||
saveMutation.isPending ||
saveInvoiceMutation.isPending ||
saveContractDocumentMutation.isPending
}
>
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
</Button>
)}
{saveMode === 'contractDocument' && (
<Button
onClick={() => saveContractDocumentMutation.mutate()}
disabled={
!contractDocumentData.documentType ||
saveMutation.isPending ||
saveInvoiceMutation.isPending ||
saveContractDocumentMutation.isPending
}
>
{saveContractDocumentMutation.isPending
? 'Wird gespeichert...'
: 'Als Vertragsdokument speichern'}
</Button>
)}
</div>
</div>
</Modal>
@@ -39,6 +39,8 @@ export default function CustomerForm() {
if (data.foundingDate) {
data.foundingDate = data.foundingDate.split('T')[0] as any;
}
// Boolean → String für <select>-Wert ('true'/'false')
(data as any).useInformalAddress = data.useInformalAddress === true ? 'true' : 'false';
reset(data);
}
}, [customer, reset]);
@@ -65,6 +67,9 @@ export default function CustomerForm() {
const submitData: any = {
type: data.type,
salutation: data.salutation || '',
useInformalAddress:
data.useInformalAddress === true ||
(data.useInformalAddress as any) === 'true',
firstName: data.firstName,
lastName: data.lastName,
companyName: data.companyName || '',
+12
View File
@@ -585,6 +585,18 @@ export const cachedEmailApi = {
);
return res.data;
},
saveAttachmentAsContractDocument: async (
emailId: number,
filename: string,
params: { documentType: string; notes?: string },
) => {
const encodedFilename = encodeURIComponent(filename);
const res = await api.post<ApiResponse<{ id: number; documentType: string; documentPath: string }>>(
`/emails/${emailId}/attachments/${encodedFilename}/save-as-contract-document`,
params,
);
return res.data;
},
};
// Contracts - Vertragsbaum für Kundenansicht