remove uploads from repo but keep empty folder

This commit is contained in:
duffyduck 2026-02-04 19:19:18 +01:00
parent 06d45734ce
commit d98c97a81f
3 changed files with 450 additions and 0 deletions

4
backend/.gitignore vendored
View File

@ -13,6 +13,10 @@ 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*

View File

@ -0,0 +1,153 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -0,0 +1,293 @@
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>
);
}