Files
opencrm/frontend/src/components/email/InsertCustomerDataModal.tsx
T
duffyduck a4895374b9 Kundendaten-Modal: nur Anbieter-Nummern, keine internen CRM-Nummern
Modal ist für Mails AN den Anbieter gedacht – interne CRM-Nummern
interessieren dort niemanden.

- formatCustomerBlock: customer.customerNumber (intern) raus,
  stattdessen contract.customerNumberAtProvider rein.
- formatContractBlock: interne contractNumber raus, restliche
  Anbieter-/Vertriebsplattform-Nummern bleiben.
- Previews ziehen ebenfalls auf customerNumberAtProvider /
  contractNumberAtProvider um, mit Hinweis-Text wenn keine
  Anbieter-Nummer hinterlegt ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:10:20 +02:00

614 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { 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 { formatDate } from '../../utils/dateFormat';
import {
bankCardAttachmentName,
identityDocAttachmentName,
serverFileToAttachment,
totalAttachmentBytes,
} from './composeAttachmentHelpers';
import type { Contract, Address, BankCard, IdentityDocument } from '../../types';
interface Props {
isOpen: boolean;
onClose: () => void;
contractId: number;
/**
* E-Mail-Adresse des Postfachs, von dem die Mail abgeschickt wird.
* Wird in der "Anrede & Name"-Section als Alternative zur Stammdaten-
* E-Mail angeboten User-Wunsch 2026-06-21: bei Kundendaten wählen,
* ob die Customer-Email oder die Stressfrei-Wechseln-Absender-Adresse
* eingefügt wird.
*/
senderEmail: string;
currentBody: string;
currentAttachments: EmailAttachment[];
onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void;
}
type EmailChoice = 'master' | 'sender' | 'none';
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
type SectionKey =
| 'customer'
| 'deliveryAddress'
| 'billingAddress'
| 'contract';
export default function InsertCustomerDataModal({
isOpen,
onClose,
contractId,
senderEmail,
currentBody,
currentAttachments,
onResult,
}: Props) {
const { data, isLoading } = useQuery({
queryKey: ['contract', contractId, 'for-insert-data'],
queryFn: () => contractApi.getById(contractId),
enabled: isOpen,
});
const contract = data?.data;
const customer = contract?.customer;
const deliveryAddress = contract?.address;
// Rechnungsadresse nur eigenständig zeigen, wenn sie sich tatsächlich
// von der Lieferadresse unterscheidet sonst doppelt im Text.
const billingAddress = useMemo(() => {
if (!contract?.billingAddress) return undefined;
if (!deliveryAddress) return contract.billingAddress;
return contract.billingAddress.id !== deliveryAddress.id ? contract.billingAddress : undefined;
}, [contract?.billingAddress, deliveryAddress]);
const bankCard = contract?.bankCard;
const identityDocument = contract?.identityDocument;
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-/Text-
// Schalter für Bank + Ausweis bleiben default-aus (User-Intent: bewusst
// entscheiden, was vertraulich verschickt wird).
const [checked, setChecked] = useState<Record<SectionKey, boolean>>({
customer: true,
deliveryAddress: true,
billingAddress: false,
contract: true,
});
// Bank: zwei unabhängige Schalter. Text fügt nur die letzten 4 IBAN-
// Stellen ein (kein vollständiger IBAN-Versand per Mail = Default-Hygiene).
const [insertBankText, setInsertBankText] = useState(false);
const [attachBankPdf, setAttachBankPdf] = useState(false);
// Ausweis: Text-Schalter fügt nur die Ausweisnummer ein, kein Geburtsdatum
// / keine Ausstellungsdaten falls der Empfänger nur die Nummer braucht.
const [insertIdentityText, setInsertIdentityText] = useState(false);
const [attachIdentityPdf, setAttachIdentityPdf] = useState(false);
// Welche E-Mail-Adresse in der Customer-Section steht:
// - 'master' = Stammdaten-E-Mail (customer.email)
// - 'sender' = Postfach-Adresse, von der die Mail abgeht (Stressfrei)
// - 'none' = E-Mail-Zeile weglassen
const [emailChoice, setEmailChoice] = useState<EmailChoice>('master');
const [busy, setBusy] = useState(false);
// Bei jedem Öffnen sinnvoll vorbelegen (sonst bleiben "checked" stale
// wenn das Modal mal mit anderen Daten wieder aufgeht).
useEffect(() => {
if (isOpen && contract) {
setChecked({
customer: !!customer,
deliveryAddress: !!deliveryAddress,
billingAddress: false, // nur wenn vorhanden, aber default aus
contract: true,
});
setInsertBankText(false);
setAttachBankPdf(false);
setInsertIdentityText(false);
setAttachIdentityPdf(false);
// Default: Stammdaten-E-Mail wenn vorhanden, sonst Absender-Adresse.
setEmailChoice(customer?.email ? 'master' : 'sender');
}
}, [isOpen, contract, customer, deliveryAddress]);
const toggle = (key: SectionKey) => {
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleClose = () => {
if (busy) return;
onClose();
};
const handleConfirm = async () => {
if (!contract) return;
setBusy(true);
try {
const blocks: string[] = [];
if (checked.customer && customer) {
const chosenEmail =
emailChoice === 'master'
? customer.email || ''
: emailChoice === 'sender'
? senderEmail
: '';
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
}
if (checked.deliveryAddress && deliveryAddress) {
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
}
if (checked.billingAddress && billingAddress) {
blocks.push(formatAddressBlock('Rechnungsadresse', billingAddress));
}
if (checked.contract) {
blocks.push(formatContractBlock(contract));
}
if (insertBankText && bankCard) {
blocks.push(formatBankBlock(bankCard));
}
if (insertIdentityText && identityDocument) {
blocks.push(formatIdentityBlock(identityDocument));
}
const textToInsert = blocks
.filter((b) => b.trim().length > 0)
.join('\n\n');
// Anhänge sammeln
const newAttachments: EmailAttachment[] = [];
let runningSize = totalAttachmentBytes(currentAttachments);
const tryAttach = async (
documentPath: string | undefined,
filename: string,
): Promise<boolean> => {
if (!documentPath) return false;
try {
const att = await serverFileToAttachment(documentPath, filename);
const approxBytes = Math.ceil(att.content.length * 0.75);
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
toast.error(`"${filename}" gesprengt das 25-MB-Anhang-Limit.`);
return false;
}
newAttachments.push(att);
runningSize += approxBytes;
return true;
} catch (err: any) {
toast.error(err?.message || `Fehler beim Anhängen von "${filename}"`);
return false;
}
};
if (attachBankPdf && bankCard?.documentPath) {
await tryAttach(
bankCard.documentPath,
bankCardAttachmentName(bankCard.iban),
);
}
if (attachIdentityPdf && identityDocument?.documentPath) {
await tryAttach(
identityDocument.documentPath,
identityDocAttachmentName(
identityDocument.type,
identityDocument.documentNumber,
),
);
}
const separator = currentBody && !currentBody.endsWith('\n') ? '\n\n' : '';
const newBody = textToInsert
? currentBody + separator + textToInsert
: currentBody;
onResult(newBody, newAttachments);
onClose();
} finally {
setBusy(false);
}
};
const nothingSelected =
!checked.customer &&
!checked.deliveryAddress &&
!checked.billingAddress &&
!checked.contract &&
!insertBankText &&
!attachBankPdf &&
!insertIdentityText &&
!attachIdentityPdf;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Kundendaten einfügen"
size="lg"
>
<div className="space-y-4">
{isLoading || !contract ? (
<div className="flex items-center justify-center py-8 text-gray-500">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Daten werden geladen
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{customer && (
<SectionRow
title="Anrede & Name"
checked={checked.customer}
onToggle={() => toggle('customer')}
preview={previewCustomer(customer, contract)}
extra={
checked.customer && (
<div className="mt-2 ml-6 space-y-1">
<div className="text-xs font-medium text-gray-600">
E-Mail im Text:
</div>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'master'}
onChange={() => setEmailChoice('master')}
disabled={!customer.email}
className="text-blue-600"
/>
<span>
Stammdaten-E-Mail
{customer.email ? (
<span className="text-gray-400"> ({customer.email})</span>
) : (
<span className="text-gray-400"> (nicht hinterlegt)</span>
)}
</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'sender'}
onChange={() => setEmailChoice('sender')}
className="text-blue-600"
/>
<span>
Absender-Adresse
<span className="text-gray-400"> ({senderEmail})</span>
</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'none'}
onChange={() => setEmailChoice('none')}
className="text-blue-600"
/>
<span>Keine E-Mail einfügen</span>
</label>
</div>
)
}
/>
)}
{deliveryAddress && (
<SectionRow
title="Lieferadresse"
checked={checked.deliveryAddress}
onToggle={() => toggle('deliveryAddress')}
preview={previewAddress(deliveryAddress)}
/>
)}
{billingAddress && (
<SectionRow
title="Rechnungsadresse"
checked={checked.billingAddress}
onToggle={() => toggle('billingAddress')}
preview={previewAddress(billingAddress)}
/>
)}
<SectionRow
title="Vertragsdaten"
checked={checked.contract}
onToggle={() => toggle('contract')}
preview={previewContract(contract)}
/>
{bankCard && (
<DualChoiceRow
title="Bankverbindung"
preview={previewBank(bankCard)}
textChecked={insertBankText}
onToggleText={() => setInsertBankText((v) => !v)}
textLabel="Letzte 4 IBAN-Stellen einfügen"
textDisabled={!lastFourIban(bankCard.iban)}
pdfChecked={attachBankPdf}
onTogglePdf={() => setAttachBankPdf((v) => !v)}
pdfLabel="Bankkarte als PDF anhängen"
pdfDisabled={!bankCard.documentPath}
/>
)}
{identityDocument && (
<DualChoiceRow
title={identityTypeLabel(identityDocument.type)}
preview={previewIdentity(identityDocument)}
textChecked={insertIdentityText}
onToggleText={() => setInsertIdentityText((v) => !v)}
textLabel={`${identityTypeLabel(identityDocument.type)}-Nummer einfügen`}
textDisabled={!identityDocument.documentNumber}
pdfChecked={attachIdentityPdf}
onTogglePdf={() => setAttachIdentityPdf((v) => !v)}
pdfLabel={`${identityTypeLabel(identityDocument.type)} als PDF anhängen`}
pdfDisabled={!identityDocument.documentPath}
/>
)}
{/* Falls weder Customer noch Address etc. da sind */}
{!customer && !deliveryAddress && !bankCard && !identityDocument && (
<p className="text-sm text-gray-500 text-center py-4">
Keine weiteren Daten am Kunden hinterlegt.
</p>
)}
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<span className="text-xs text-gray-500">
Text wird ans Ende der Nachricht angehängt.
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
onClick={handleConfirm}
disabled={busy || isLoading || nothingSelected}
>
{busy ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Einfügen
</>
) : (
'Einfügen'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}
// ==================== UI-Helper ====================
interface SectionRowProps {
title: string;
checked: boolean;
onToggle: () => void;
preview: string;
extra?: React.ReactNode;
}
interface DualChoiceRowProps {
title: string;
preview: string;
textChecked: boolean;
onToggleText: () => void;
textLabel: string;
textDisabled?: boolean;
pdfChecked: boolean;
onTogglePdf: () => void;
pdfLabel: string;
pdfDisabled?: boolean;
}
/**
* Sections, die unabhängig Text und PDF anbieten (Bank, Ausweis).
* Keine primäre Checkbox beide Schalter wirken einzeln, deshalb
* kein "alle-ein/alle-aus" auf Section-Ebene nötig.
*/
function DualChoiceRow({
title,
preview,
textChecked,
onToggleText,
textLabel,
textDisabled,
pdfChecked,
onTogglePdf,
pdfLabel,
pdfDisabled,
}: DualChoiceRowProps) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="text-xs text-gray-500 mt-1">{preview}</div>
<div className="mt-2 space-y-1">
<label className={`flex items-center gap-2 text-xs cursor-pointer ${textDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
<input
type="checkbox"
checked={textChecked}
onChange={onToggleText}
disabled={textDisabled}
className="rounded"
/>
<span>{textLabel}</span>
</label>
<label className={`flex items-center gap-2 text-xs cursor-pointer ${pdfDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
<input
type="checkbox"
checked={pdfChecked}
onChange={onTogglePdf}
disabled={pdfDisabled}
className="rounded"
/>
<span>
{pdfLabel}
{pdfDisabled && <span className="ml-1">(keine PDF hinterlegt)</span>}
</span>
</label>
</div>
</div>
);
}
function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="mt-1 rounded"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700">{title}</div>
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap font-sans">
{preview}
</pre>
</div>
</label>
{extra}
</div>
);
}
// ==================== Text-Block-Formatierung ====================
function fullName(
customer: { salutation?: string; firstName: string; lastName: string; companyName?: string },
contractType: string,
): string {
if (contractType === 'BUSINESS' && customer.companyName) {
return customer.companyName;
}
const parts: string[] = [];
if (customer.salutation) parts.push(customer.salutation);
parts.push(customer.firstName);
parts.push(customer.lastName);
return parts.filter(Boolean).join(' ');
}
// User-Wunsch 2026-06-21: das Modal ist für Mails AN den Anbieter gedacht.
// Die interne CRM-Kundennummer / -Vertragsnummer interessiert dort
// niemanden relevant ist nur, was der Anbieter selbst vergeben hat
// (`customerNumberAtProvider`, `contractNumberAtProvider`). Wir blenden
// die internen Nummern komplett aus.
function formatCustomerBlock(
customer: NonNullable<Contract['customer']>,
contract: Contract,
email: string,
): string {
const lines: string[] = ['Kundendaten:'];
lines.push(fullName(customer, contract.type));
if (contract.customerNumberAtProvider) {
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
}
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
if (email) lines.push(`E-Mail: ${email}`);
if (customer.phone) lines.push(`Telefon: ${customer.phone}`);
if (customer.mobile) lines.push(`Mobil: ${customer.mobile}`);
return lines.join('\n');
}
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
return [
fullName(customer, contract.type),
contract.customerNumberAtProvider
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
: '',
]
.filter(Boolean)
.join(' · ');
}
function formatAddressBlock(label: string, addr: Address): string {
const lines: string[] = [`${label}:`];
lines.push(`${addr.street} ${addr.houseNumber}`);
lines.push(`${addr.postalCode} ${addr.city}`);
if (addr.country && addr.country.toLowerCase() !== 'deutschland' && addr.country !== 'DE') {
lines.push(addr.country);
}
return lines.join('\n');
}
function previewAddress(addr: Address): string {
return `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}`;
}
// Interne `contractNumber` raus (User-Wunsch 2026-06-21): für eine Mail
// an den Provider zählt nur die Vertragsnummer, die der Provider selbst
// vergeben hat. Vertriebsplattform-Nummern bleiben drin die nutzt der
// CRM-Mitarbeiter teilweise auch für die Plattform-Korrespondenz.
function formatContractBlock(c: Contract): string {
const lines: string[] = ['Vertragsdaten:'];
if (c.provider?.name) lines.push(`Anbieter: ${c.provider.name}`);
if (c.tariff?.name) lines.push(`Tarif: ${c.tariff.name}`);
if (c.customerNumberAtProvider) lines.push(`Kundennummer beim Anbieter: ${c.customerNumberAtProvider}`);
if (c.contractNumberAtProvider) lines.push(`Vertragsnummer beim Anbieter: ${c.contractNumberAtProvider}`);
if (c.orderNumberAtSalesPlatform) lines.push(`Auftragsnummer Vertriebsplattform: ${c.orderNumberAtSalesPlatform}`);
if (c.customerNumberAtSalesPlatform) lines.push(`Kundennummer Vertriebsplattform: ${c.customerNumberAtSalesPlatform}`);
if (c.contractNumberAtSalesPlatform) lines.push(`Vertragsnummer Vertriebsplattform: ${c.contractNumberAtSalesPlatform}`);
if (c.startDate) lines.push(`Vertragsbeginn: ${formatDate(c.startDate)}`);
if (c.endDate) lines.push(`Vertragsende: ${formatDate(c.endDate)}`);
return lines.join('\n');
}
function previewContract(c: Contract): string {
const parts: string[] = [];
if (c.contractNumberAtProvider) {
parts.push(`Anbieter-Vtr.: ${c.contractNumberAtProvider}`);
} else if (c.provider?.name) {
parts.push('(keine Anbieter-Vertragsnummer hinterlegt)');
}
if (c.provider?.name) parts.push(c.provider.name);
if (c.tariff?.name) parts.push(c.tariff.name);
return parts.join(' · ');
}
// User-Wunsch 2026-06-21: nur die letzten 4 IBAN-Stellen einfügen, nicht
// die komplette IBAN/BIC/Bank-Liste. Vollständige Kontonummern per Mail
// versenden ist sowieso heikel der Empfänger kann sich mit den letzten
// 4 Stellen für Identifikationszwecke ausweisen, ohne dass die ganze
// IBAN im Mail-Verlauf hängenbleibt.
function lastFourIban(iban: string | undefined | null): string {
if (!iban) return '';
return iban.replace(/\s+/g, '').slice(-4);
}
function formatBankBlock(b: BankCard): string {
const last4 = lastFourIban(b.iban);
if (!last4) return '';
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
}
function previewBank(b: BankCard): string {
const last4 = lastFourIban(b.iban);
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
}
function identityTypeLabel(type: IdentityDocument['type']): string {
switch (type) {
case 'PASSPORT': return 'Reisepass';
case 'DRIVERS_LICENSE': return 'Führerschein';
case 'OTHER': return 'Ausweisdokument';
case 'ID_CARD':
default: return 'Personalausweis';
}
}
// User-Wunsch 2026-06-21: nur die Ausweisnummer einfügen, keine
// Behörde / Daten wenn der Empfänger mehr Details braucht, soll er
// die beigefügte PDF benutzen.
function formatIdentityBlock(d: IdentityDocument): string {
if (!d.documentNumber) return '';
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
}
function previewIdentity(d: IdentityDocument): string {
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
}