E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen

Zwei neue Buttons im Compose-Modal (nur sichtbar bei Vertrag-
Kontext):

- Vertragsdokumente: listet alle am Vertrag gespeicherten
  ContractDocuments gruppiert nach documentType. Auswahl →
  Token-Download via fileUrl → base64 → Anhang.
- Kundendaten einfügen: zeigt Sections nur wenn Daten vorhanden
  (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank,
  Ausweis). Bei Bank/Ausweis zusätzlich Sub-Checkbox "als PDF
  anhängen" wenn documentPath vorhanden. Text-Blöcke ans Body-
  Ende, PDFs in attachments[]. 25-MB-Limit beidseitig geprüft.

Helpers in composeAttachmentHelpers.ts:
- serverFileToAttachment(path, filename) für Token-URL→Blob→base64
- totalAttachmentBytes mit ~33% base64-Overhead
- sprechende Dateinamen via bankCardAttachmentName /
  identityDocAttachmentName

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:55:13 +02:00
parent 4ab0340473
commit 5293af18a5
5 changed files with 827 additions and 10 deletions
@@ -0,0 +1,186 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { FileText, Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, EmailAttachment } from '../../services/api';
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
interface Props {
isOpen: boolean;
onClose: () => void;
contractId: number;
currentAttachments: EmailAttachment[];
onAttach: (added: EmailAttachment[]) => void;
}
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // identisch zur Compose-Modal
export default function AttachContractDocumentsModal({
isOpen,
onClose,
contractId,
currentAttachments,
onAttach,
}: Props) {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract-documents', contractId],
queryFn: () => contractApi.getDocuments(contractId),
enabled: isOpen,
});
const documents = data?.data || [];
const toggle = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleClose = () => {
if (busy) return; // Kein Abbruch während Download läuft
setSelected(new Set());
onClose();
};
const handleAttach = async () => {
if (selected.size === 0) {
handleClose();
return;
}
setBusy(true);
const docsToFetch = documents.filter((d) => selected.has(d.id));
const newAttachments: EmailAttachment[] = [];
let runningSize = totalAttachmentBytes(currentAttachments);
try {
for (const doc of docsToFetch) {
try {
const att = await serverFileToAttachment(doc.documentPath, doc.originalName);
const approxBytes = Math.ceil(att.content.length * 0.75);
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
toast.error(
`Maximale Gesamtgröße erreicht (25 MB). "${doc.originalName}" und folgende übersprungen.`,
{ duration: 6000 },
);
break;
}
newAttachments.push(att);
runningSize += approxBytes;
} catch (err: any) {
toast.error(err?.message || `Fehler beim Anhängen von "${doc.originalName}"`);
}
}
if (newAttachments.length > 0) {
onAttach(newAttachments);
toast.success(
newAttachments.length === 1
? '1 Dokument angehängt'
: `${newAttachments.length} Dokumente angehängt`,
);
}
setSelected(new Set());
onClose();
} finally {
setBusy(false);
}
};
// Nach documentType gruppieren für übersichtliche Darstellung
const grouped = documents.reduce<Record<string, typeof documents>>((acc, doc) => {
const key = doc.documentType || 'Sonstiges';
if (!acc[key]) acc[key] = [];
acc[key].push(doc);
return acc;
}, {});
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Vertragsdokumente anhängen"
size="lg"
>
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Dokumente werden geladen
</div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<FileText className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">Keine Dokumente am Vertrag hinterlegt</p>
</div>
) : (
<div className="space-y-4 max-h-96 overflow-y-auto">
{Object.entries(grouped).map(([type, docs]) => (
<div key={type}>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
{type}
</div>
<div className="space-y-1">
{docs.map((doc) => (
<label
key={doc.id}
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
checked={selected.has(doc.id)}
onChange={() => toggle(doc.id)}
disabled={busy}
className="mt-0.5 rounded"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="truncate">{doc.originalName}</span>
</div>
{doc.notes && (
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
{doc.notes}
</div>
)}
</div>
</label>
))}
</div>
</div>
))}
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'}
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
onClick={handleAttach}
disabled={busy || selected.size === 0}
>
{busy ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Anhängen
</>
) : (
'Anhängen'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}