Compare commits
No commits in common. "d98c97a81f9316d8a11cf427baff504a4357de67" and "eb313f82914a7277bd1ca98c81d6cc42d701551e" have entirely different histories.
d98c97a81f
...
eb313f8291
|
|
@ -13,10 +13,6 @@ dist/
|
||||||
prisma/backups/*
|
prisma/backups/*
|
||||||
!prisma/backups/.gitkeep
|
!prisma/backups/.gitkeep
|
||||||
|
|
||||||
# Uploads (user files, keep folder structure)
|
|
||||||
uploads/*
|
|
||||||
!uploads/.gitkeep
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Konvertiert HTML zu PDF mit Puppeteer
|
|
||||||
*/
|
|
||||||
export async function htmlToPdf(html: string): Promise<Buffer> {
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
|
||||||
|
|
||||||
const pdfBuffer = await page.pdf({
|
|
||||||
format: 'A4',
|
|
||||||
margin: {
|
|
||||||
top: '20mm',
|
|
||||||
right: '15mm',
|
|
||||||
bottom: '20mm',
|
|
||||||
left: '15mm',
|
|
||||||
},
|
|
||||||
printBackground: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return Buffer.from(pdfBuffer);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Baut ein HTML-Dokument für eine E-Mail mit Header
|
|
||||||
*/
|
|
||||||
export function buildEmailHtml(email: {
|
|
||||||
subject?: string | null;
|
|
||||||
fromAddress: string;
|
|
||||||
fromName?: string | null;
|
|
||||||
toAddresses: string;
|
|
||||||
receivedAt: Date;
|
|
||||||
htmlBody?: string | null;
|
|
||||||
textBody?: string | null;
|
|
||||||
}): string {
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return new Date(date).toLocaleString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// To-Adressen parsen (JSON Array)
|
|
||||||
let toList: string[] = [];
|
|
||||||
try {
|
|
||||||
toList = JSON.parse(email.toAddresses);
|
|
||||||
} catch {
|
|
||||||
toList = [email.toAddresses];
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromDisplay = email.fromName
|
|
||||||
? `${email.fromName} <${email.fromAddress}>`
|
|
||||||
: email.fromAddress;
|
|
||||||
|
|
||||||
const body = email.htmlBody || `<pre style="white-space: pre-wrap; font-family: inherit;">${email.textBody || ''}</pre>`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 12pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.email-header {
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-bottom: 2px solid #ddd;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.email-header h1 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
font-size: 16pt;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
.email-header table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.email-header th {
|
|
||||||
text-align: left;
|
|
||||||
width: 60px;
|
|
||||||
padding: 3px 10px 3px 0;
|
|
||||||
color: #666;
|
|
||||||
font-weight: normal;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.email-header td {
|
|
||||||
padding: 3px 0;
|
|
||||||
}
|
|
||||||
.email-body {
|
|
||||||
padding: 0 5px;
|
|
||||||
}
|
|
||||||
.email-body img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="email-header">
|
|
||||||
<h1>${escapeHtml(email.subject || '(Kein Betreff)')}</h1>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<th>Von:</th>
|
|
||||||
<td>${escapeHtml(fromDisplay)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>An:</th>
|
|
||||||
<td>${escapeHtml(toList.join(', '))}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Datum:</th>
|
|
||||||
<td>${formatDate(email.receivedAt)}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="email-body">
|
|
||||||
${body}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
|
||||||
import Modal from '../ui/Modal';
|
|
||||||
import Button from '../ui/Button';
|
|
||||||
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
interface SaveEmailAsPdfModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
emailId: number;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectedTarget = {
|
|
||||||
entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract';
|
|
||||||
entityId?: number;
|
|
||||||
targetKey: string;
|
|
||||||
hasDocument: boolean;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SaveEmailAsPdfModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
emailId,
|
|
||||||
onSuccess,
|
|
||||||
}: SaveEmailAsPdfModalProps) {
|
|
||||||
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Ziele laden (gleiche wie bei Anhängen)
|
|
||||||
const { data: targetsData, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['attachment-targets', emailId],
|
|
||||||
queryFn: () => cachedEmailApi.getAttachmentTargets(emailId),
|
|
||||||
enabled: isOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
const targets = targetsData?.data;
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: () => {
|
|
||||||
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
|
||||||
return cachedEmailApi.saveEmailAsPdf(emailId, {
|
|
||||||
entityType: selectedTarget.entityType,
|
|
||||||
entityId: selectedTarget.entityId,
|
|
||||||
targetKey: selectedTarget.targetKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('E-Mail als PDF gespeichert');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
|
||||||
|
|
||||||
// Spezifische Ansichten aktualisieren
|
|
||||||
if (targets?.customer?.id) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer', targets.customer.id.toString()] });
|
|
||||||
}
|
|
||||||
if (targets?.contract?.id) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess?.();
|
|
||||||
handleClose();
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(error.message || 'Fehler beim Speichern');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setSelectedTarget(null);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
|
||||||
const newExpanded = new Set(expandedSections);
|
|
||||||
if (newExpanded.has(section)) {
|
|
||||||
newExpanded.delete(section);
|
|
||||||
} else {
|
|
||||||
newExpanded.add(section);
|
|
||||||
}
|
|
||||||
setExpandedSections(newExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectSlot = (
|
|
||||||
entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract',
|
|
||||||
slot: AttachmentTargetSlot,
|
|
||||||
entityId?: number,
|
|
||||||
parentLabel?: string
|
|
||||||
) => {
|
|
||||||
setSelectedTarget({
|
|
||||||
entityType,
|
|
||||||
entityId,
|
|
||||||
targetKey: slot.key,
|
|
||||||
hasDocument: slot.hasDocument,
|
|
||||||
label: parentLabel ? `${parentLabel} → ${slot.label}` : slot.label,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSlots = (
|
|
||||||
slots: AttachmentTargetSlot[],
|
|
||||||
entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract',
|
|
||||||
entityId?: number,
|
|
||||||
parentLabel?: string
|
|
||||||
) => {
|
|
||||||
return slots.map((slot) => {
|
|
||||||
const isSelected =
|
|
||||||
selectedTarget?.entityType === entityType &&
|
|
||||||
selectedTarget?.entityId === entityId &&
|
|
||||||
selectedTarget?.targetKey === slot.key;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={slot.key}
|
|
||||||
onClick={() => handleSelectSlot(entityType, slot, entityId, parentLabel)}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-3 p-3 cursor-pointer transition-colors rounded-lg ml-4
|
|
||||||
${isSelected ? 'bg-blue-100 ring-2 ring-blue-500' : 'hover:bg-gray-100'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-gray-900">{slot.label}</span>
|
|
||||||
{slot.hasDocument && (
|
|
||||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-800">
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
Vorhanden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isSelected && <Check className="w-5 h-5 text-blue-600" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderEntityWithSlots = (
|
|
||||||
entity: AttachmentEntityWithSlots,
|
|
||||||
entityType: 'identityDocument' | 'bankCard'
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<div key={entity.id} className="mb-2">
|
|
||||||
<div className="text-sm font-medium text-gray-700 px-3 py-1 bg-gray-50 rounded">
|
|
||||||
{entity.label}
|
|
||||||
</div>
|
|
||||||
{renderSlots(entity.slots, entityType, entity.id, entity.label)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSection = (
|
|
||||||
title: string,
|
|
||||||
sectionKey: string,
|
|
||||||
icon: React.ReactNode,
|
|
||||||
children: React.ReactNode,
|
|
||||||
isEmpty: boolean = false
|
|
||||||
) => {
|
|
||||||
const isExpanded = expandedSections.has(sectionKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection(sectionKey)}
|
|
||||||
className="w-full flex items-center gap-2 p-3 bg-gray-50 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
|
||||||
)}
|
|
||||||
{icon}
|
|
||||||
<span className="font-medium text-gray-900">{title}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="p-2">
|
|
||||||
{isEmpty ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">Keine Einträge vorhanden</p>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Info */}
|
|
||||||
<div className="p-3 bg-blue-50 rounded-lg">
|
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
Die E-Mail wird als PDF exportiert (inkl. Absender, Empfänger, Datum und Inhalt) und im gewählten Dokumentenfeld gespeichert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
|
|
||||||
Fehler beim Laden der Dokumentziele
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Targets */}
|
|
||||||
{targets && (
|
|
||||||
<div className="space-y-3 max-h-96 overflow-auto">
|
|
||||||
{/* Kunde */}
|
|
||||||
{renderSection(
|
|
||||||
`Kunde: ${targets.customer.name}`,
|
|
||||||
'customer',
|
|
||||||
<User className="w-4 h-4 text-blue-600" />,
|
|
||||||
renderSlots(targets.customer.slots, 'customer'),
|
|
||||||
targets.customer.slots.length === 0
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ausweise */}
|
|
||||||
{renderSection(
|
|
||||||
'Ausweisdokumente',
|
|
||||||
'identityDocuments',
|
|
||||||
<IdCard className="w-4 h-4 text-green-600" />,
|
|
||||||
targets.identityDocuments.map((doc) =>
|
|
||||||
renderEntityWithSlots(doc, 'identityDocument')
|
|
||||||
),
|
|
||||||
targets.identityDocuments.length === 0
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bankkarten */}
|
|
||||||
{renderSection(
|
|
||||||
'Bankkarten',
|
|
||||||
'bankCards',
|
|
||||||
<CreditCard className="w-4 h-4 text-purple-600" />,
|
|
||||||
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
|
|
||||||
targets.bankCards.length === 0
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vertrag */}
|
|
||||||
{targets.contract && renderSection(
|
|
||||||
`Vertrag: ${targets.contract.contractNumber}`,
|
|
||||||
'contract',
|
|
||||||
<FileText className="w-4 h-4 text-orange-600" />,
|
|
||||||
renderSlots(targets.contract.slots, 'contract'),
|
|
||||||
targets.contract.slots.length === 0
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!targets.contract && (
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
|
||||||
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
|
|
||||||
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
|
|
||||||
Vertragsdokumente als Ziel auswählen zu können.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warning if replacing */}
|
|
||||||
{selectedTarget?.hasDocument && (
|
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-sm text-yellow-800">
|
|
||||||
<strong>Achtung:</strong> An diesem Feld ist bereits ein Dokument hinterlegt. Das
|
|
||||||
vorhandene Dokument wird durch die PDF ersetzt.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
|
||||||
<Button variant="secondary" onClick={handleClose}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveMutation.mutate()}
|
|
||||||
disabled={!selectedTarget || saveMutation.isPending}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue