E-Mail als PDF speichern: Tab "Vertragsdokument" ergänzt

Bisher hatte das "E-Mail als PDF speichern"-Modal nur die Tabs
"Als Dokument" + "Als Rechnung" (nur Energieverträge). Wenn die
E-Mail einem Vertrag zugeordnet ist, fehlte die Möglichkeit, sie
direkt als Vertragsdokument (Auftragsformular, Lieferbestätigung
etc.) zu hinterlegen – analog zum Anhang-Modal.

Backend: neuer Endpoint POST /api/emails/:id/save-as-contract-document
{ documentType, notes?, deliveryDate? } – generiert das Mail-PDF,
speichert es unter /uploads/contract-documents und legt einen
ContractDocument-Eintrag an. Bei documentType "Lieferbestätigung"
wird der bestehende maybeActivateOnDeliveryConfirmation-Workflow
getriggert (DRAFT → ACTIVE, startDate-Übernahme).

Frontend: SaveEmailAsPdfModal bekommt den dritten Tab parallel zu
SaveAttachmentModal. Tab erscheint, sobald die E-Mail einem Vertrag
zugeordnet ist (auch bei Nicht-Energieverträgen); Tab "Als Rechnung"
bleibt auf Energieverträge beschränkt. Dokumenttyp-Dropdown und
Notizen-Feld werden aus dem Anhang-Modal übernommen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:15:23 +02:00
parent 71d3ea7a2e
commit 0f4ffe3c32
4 changed files with 236 additions and 15 deletions
@@ -1814,6 +1814,93 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
}
}
// ==================== SAVE EMAIL AS CONTRACT DOCUMENT ====================
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen.
// Parallel zu saveAttachmentAsContractDocument (Anhang-Variante) damit
// auch reine Mail-Bestätigungen ohne Anhang als Auftragsformular/
// Lieferbestätigung etc. an einem Vertrag landen können.
export async function saveEmailAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
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;
}
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Empfänger-Adressen parsen
let toAddresses: string[] = [];
let ccAddresses: string[] = [];
try { toAddresses = JSON.parse(email.toAddresses); } catch { toAddresses = [email.toAddresses]; }
try { if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses); } catch { /* ignore */ }
const pdfBuffer = await generateEmailPdf({
from: email.fromAddress,
to: toAddresses.join(', '),
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
subject: email.subject || '(Kein Betreff)',
date: email.receivedAt,
bodyText: email.textBody || undefined,
bodyHtml: email.htmlBody || undefined,
});
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, pdfBuffer);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveEmailAsContractDocument error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
}
}
// ==================== SAVE ATTACHMENT AS INVOICE ====================
// E-Mail-Anhang als Rechnung speichern
+9
View File
@@ -194,6 +194,15 @@ router.post(
cachedEmailController.saveEmailAsInvoice
);
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen
// POST /api/emails/:id/save-as-contract-document { documentType, notes?, deliveryDate? }
router.post(
'/emails/:id/save-as-contract-document',
authenticate,
requirePermission('contracts:update'),
cachedEmailController.saveEmailAsContractDocument
);
// Anhang als Rechnung speichern
// POST /api/emails/:id/attachments/:filename/save-as-invoice { invoiceDate, invoiceType, notes? }
router.post(
@@ -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 SaveEmailAsPdfModalProps {
isOpen: boolean;
onClose: () => void;
@@ -24,7 +35,7 @@ type SelectedTarget = {
label: string;
};
type SaveMode = 'document' | 'invoice';
type SaveMode = 'document' | 'invoice' | 'contractDocument';
export default function SaveEmailAsPdfModal({
isOpen,
@@ -40,6 +51,11 @@ export default function SaveEmailAsPdfModal({
invoiceType: 'INTERIM' as InvoiceType,
notes: '',
});
const [contractDocumentData, setContractDocumentData] = useState({
documentType: CONTRACT_DOCUMENT_TYPES[0],
notes: '',
deliveryDate: new Date().toISOString().split('T')[0],
});
const queryClient = useQueryClient();
// Ziele laden (gleiche wie bei Anhängen)
@@ -51,8 +67,10 @@ export default function SaveEmailAsPdfModal({
const targets = targetsData?.data;
// Prüfen ob es ein Energievertrag ist
// Prüfen ob es ein Energievertrag ist (für Rechnungs-Modus)
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
// Vertrag zugeordnet? → Vertragsdokument-Modus erlaubt
const hasContract = !!targets?.contract;
const saveMutation = useMutation({
mutationFn: () => {
@@ -111,6 +129,33 @@ export default function SaveEmailAsPdfModal({
},
});
const saveContractDocumentMutation = useMutation({
mutationFn: () => {
const isDelivery = contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung';
return cachedEmailApi.saveEmailAsContractDocument(emailId, {
documentType: contractDocumentData.documentType,
notes: contractDocumentData.notes || undefined,
deliveryDate: isDelivery ? contractDocumentData.deliveryDate : undefined,
});
},
onSuccess: () => {
toast.success('E-Mail 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');
@@ -119,6 +164,11 @@ export default function SaveEmailAsPdfModal({
invoiceType: 'INTERIM',
notes: '',
});
setContractDocumentData({
documentType: CONTRACT_DOCUMENT_TYPES[0],
notes: '',
deliveryDate: new Date().toISOString().split('T')[0],
});
onClose();
};
@@ -235,7 +285,7 @@ export default function SaveEmailAsPdfModal({
);
};
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending;
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending || saveContractDocumentMutation.isPending;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
@@ -263,12 +313,13 @@ export default function SaveEmailAsPdfModal({
{targets && (
<>
{/* Mode Toggle für Energieverträge */}
{isEnergyContract && (
{/* Mode Toggle: Vertragsdokument-Tab wenn ein Vertrag verknüpft ist,
Rechnungs-Tab nur bei Energieverträgen. */}
{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 +328,21 @@ export default function SaveEmailAsPdfModal({
<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>
{isEnergyContract && (
<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'
@@ -288,6 +351,7 @@ export default function SaveEmailAsPdfModal({
<Receipt className="w-4 h-4" />
Als Rechnung
</button>
)}
</div>
)}
@@ -377,6 +441,46 @@ export default function SaveEmailAsPdfModal({
/>
</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">
Die E-Mail 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..."
/>
{contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<label className="block text-sm font-medium text-gray-700 mb-1">Lieferdatum</label>
<input
type="date"
value={contractDocumentData.deliveryDate}
onChange={(e) => setContractDocumentData({ ...contractDocumentData, deliveryDate: e.target.value })}
className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<p className="text-xs text-gray-600 mt-1">
Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen.
</p>
</div>
)}
</div>
)}
</>
)}
@@ -396,14 +500,15 @@ export default function SaveEmailAsPdfModal({
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
{saveMode === 'document' ? (
{saveMode === 'document' && (
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || isPending}
>
{isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
</Button>
) : (
)}
{saveMode === 'invoice' && (
<Button
onClick={() => saveInvoiceMutation.mutate()}
disabled={!invoiceData.invoiceDate || isPending}
@@ -411,6 +516,14 @@ export default function SaveEmailAsPdfModal({
{isPending ? 'Wird erstellt...' : 'Als Rechnung speichern'}
</Button>
)}
{saveMode === 'contractDocument' && (
<Button
onClick={() => saveContractDocumentMutation.mutate()}
disabled={!contractDocumentData.documentType || isPending}
>
{isPending ? 'Wird erstellt...' : 'Als Vertragsdokument speichern'}
</Button>
)}
</div>
</div>
</Modal>
+12
View File
@@ -710,6 +710,18 @@ export const cachedEmailApi = {
);
return res.data;
},
// E-Mail als Vertragsdokument speichern (PDF aus der Mail wird als
// ContractDocument hinterlegt parallel zur Anhang-Variante)
saveEmailAsContractDocument: async (
emailId: number,
params: { documentType: string; notes?: string; deliveryDate?: string },
) => {
const res = await api.post<ApiResponse<{ id: number; documentType: string; documentPath: string }>>(
`/emails/${emailId}/save-as-contract-document`,
params,
);
return res.data;
},
// Anhang als Rechnung speichern (für Energieverträge)
saveAttachmentAsInvoice: async (emailId: number, filename: string, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
const encodedFilename = encodeURIComponent(filename);