import { useState, useEffect } from 'react'; import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { pushHistory, popHistory } from '../../utils/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api'; import { EmailClientTab } from '../../components/email'; import ReferralsTab from '../../components/customers/ReferralsTab'; import { useAuth } from '../../context/AuthContext'; import Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; import Badge from '../../components/ui/Badge'; import Tabs from '../../components/ui/Tabs'; import Modal from '../../components/ui/Modal'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; import FileUpload from '../../components/ui/FileUpload'; import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink, Images } from 'lucide-react'; import JpgToPdfModal from '../../components/ui/JpgToPdfModal'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; import BirthdayManagementModal from '../../components/BirthdayManagementModal'; import { formatDate } from '../../utils/dateFormat'; import { getContractTypeInfo } from '../../utils/contractInfo'; import { useProviderSettings } from '../../hooks/useProviderSettings'; import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types'; import { fileUrl, viewUrl } from '../../utils/fileUrl'; export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) { const { id } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { hasPermission, isCustomerPortal } = useAuth(); const location = useLocation(); const back = popHistory(location.state, isCustomerPortal ? '/' : '/customers'); const [searchParams, setSearchParams] = useSearchParams(); const customerId = portalCustomerId || parseInt(id!); const defaultTab = searchParams.get('tab') || 'addresses'; const [activeTab, setActiveTab] = useState(defaultTab); const { customerEmailLabel } = useProviderSettings(); // Tab-Wechsel in URL synchronisieren (für Browser-History) const handleTabChange = (tabId: string) => { setActiveTab(tabId); setSearchParams({ tab: tabId }, { replace: true }); }; const [showAddressModal, setShowAddressModal] = useState(false); const [showBankCardModal, setShowBankCardModal] = useState(false); const [showDocumentModal, setShowDocumentModal] = useState(false); const [showMeterModal, setShowMeterModal] = useState(false); const [showStressfreiEmailModal, setShowStressfreiEmailModal] = useState(false); const [showBirthdayModal, setShowBirthdayModal] = useState(false); const [showInactive, setShowInactive] = useState(false); const [editingBankCard, setEditingBankCard] = useState(null); const [editingDocument, setEditingDocument] = useState(null); const [editingAddress, setEditingAddress] = useState
(null); const [editingMeter, setEditingMeter] = useState(null); const [editingStressfreiEmail, setEditingStressfreiEmail] = useState(null); const { data: customer, isLoading } = useQuery({ queryKey: ['customer', customerId], queryFn: () => customerApi.getById(customerId), }); // Consent-Status prüfen const { data: consentStatusData } = useQuery({ queryKey: ['consent-status', customerId], queryFn: () => gdprApi.checkConsentStatus(customerId), enabled: !!customer?.data, }); const hasConsentApproval = consentStatusData?.data?.hasConsent ?? false; const deleteMutation = useMutation({ mutationFn: () => customerApi.delete(customerId), onSuccess: () => { navigate('/customers'); }, }); if (isLoading) { return
Laden...
; } if (!customer?.data) { return
Kunde nicht gefunden
; } const c = customer.data; // Gesperrter Inhalt für Tabs ohne Einwilligung const blockedContent = ( handleTabChange('consents')} /> ); const tabs = [ { id: 'addresses', label: 'Adressen', content: ( setShowAddressModal(true)} onEdit={(addr) => setEditingAddress(addr)} /> ), }, { id: 'bankcards', label: 'Bankkarten', content: hasConsentApproval ? ( setShowInactive(!showInactive)} onAdd={() => setShowBankCardModal(true)} onEdit={(card) => setEditingBankCard(card)} /> ) : blockedContent, }, { id: 'documents', label: 'Ausweise', content: hasConsentApproval ? ( setShowInactive(!showInactive)} onAdd={() => setShowDocumentModal(true)} onEdit={(doc) => setEditingDocument(doc)} /> ) : blockedContent, }, { id: 'meters', label: 'Zähler', content: hasConsentApproval ? ( setShowInactive(!showInactive)} onAdd={() => setShowMeterModal(true)} onEdit={(meter) => setEditingMeter(meter)} /> ) : blockedContent, }, ...(!isCustomerPortal ? [{ id: 'stressfrei', label: customerEmailLabel, content: ( setShowInactive(!showInactive)} onAdd={() => setShowStressfreiEmailModal(true)} onEdit={(email) => setEditingStressfreiEmail(email)} /> ), }] : []), { id: 'emails', label: 'E-Mail-Postfach', content: hasConsentApproval ? ( ) : blockedContent, }, { id: 'contracts', label: 'Verträge', content: hasConsentApproval ? ( ) : blockedContent, }, ...(hasPermission('customers:update') ? [{ id: 'portal', label: 'Portal', content: ( ), }] : []), ...(hasPermission('customers:read') && !isCustomerPortal ? [{ id: 'referrals', label: 'Geworben / angeworben', // Bewusst ohne Consent-Gate (hasConsentApproval): funktioniert auch // ohne Datenschutz-Einwilligung des Kunden. Nur Staff, nie Portal. content: ( ), }] : []), ...(hasPermission('customers:read') && !isCustomerPortal ? [{ id: 'consents', label: 'Einwilligungen / Datenschutz', content: ( { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['consent-status', customerId] }); }} /> ), }] : []), ]; return (

{c.type === 'BUSINESS' && c.companyName ? c.companyName : `${c.firstName} ${c.lastName}`}

{c.customerNumber}

{hasPermission('customers:update') && ( )} {hasPermission('customers:delete') && ( )}
Typ
{c.type === 'BUSINESS' ? 'Geschäftskunde' : 'Privatkunde'}
{c.salutation && (
Anrede
{c.salutation}
)}
Anrede per
{c.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'} Standard für alle Mitarbeiter
{!isCustomerPortal && (
Anrede für mich
)}
Vorname
{c.firstName}
Nachname
{c.lastName}
{c.companyName && (
Firma
{c.companyName}
)} {c.foundingDate && (
Gründungsdatum
{new Date(c.foundingDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
)} {c.birthDate && (
Geburtsdatum
{new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {!isCustomerPortal && hasPermission('customers:update') && ( )}
)} {c.birthPlace && (
Geburtsort
{c.birthPlace}
)}
{c.email && (
E-Mail
{c.email} {(c.stressfreiEmails?.length ?? 0) > 0 && ( )}
)} {c.phone && (
Telefon
{c.phone}
)} {c.mobile && ( )}
{c.type === 'BUSINESS' && ( queryClient.invalidateQueries({ queryKey: ['customer', customerId] })} /> )} {c.notes && (

{c.notes}

)} `${location.pathname}?tab=${tabId}`} /> setShowAddressModal(false)} customerId={customerId} /> setEditingAddress(null)} customerId={customerId} address={editingAddress} /> setShowBankCardModal(false)} customerId={customerId} /> setEditingBankCard(null)} customerId={customerId} bankCard={editingBankCard} /> setShowDocumentModal(false)} customerId={customerId} /> setEditingDocument(null)} customerId={customerId} document={editingDocument} /> setShowMeterModal(false)} customerId={customerId} addresses={c.addresses || []} existingMeters={c.meters || []} /> setEditingMeter(null)} customerId={customerId} meter={editingMeter} addresses={c.addresses || []} existingMeters={c.meters || []} /> setShowStressfreiEmailModal(false)} customerId={customerId} customerEmail={customer?.data?.email} /> setEditingStressfreiEmail(null)} customerId={customerId} email={editingStressfreiEmail} customerEmail={customer?.data?.email} /> {showBirthdayModal && c && ( setShowBirthdayModal(false)} /> )}
); } // Business Data Card with Document Uploads function BusinessDataCard({ customer, canEdit, onUpdate, }: { customer: Customer; canEdit: boolean; onUpdate: () => void; }) { const handleBusinessRegUpload = async (file: File) => { try { await uploadApi.uploadBusinessRegistration(customer.id, file); onUpdate(); } catch (error) { console.error('Upload fehlgeschlagen:', error); alert('Upload fehlgeschlagen'); } }; const handleBusinessRegDelete = async () => { if (!confirm('Gewerbeanmeldung wirklich löschen?')) return; try { await uploadApi.deleteBusinessRegistration(customer.id); onUpdate(); } catch (error) { console.error('Löschen fehlgeschlagen:', error); alert('Löschen fehlgeschlagen'); } }; const handleCommercialRegUpload = async (file: File) => { try { await uploadApi.uploadCommercialRegister(customer.id, file); onUpdate(); } catch (error) { console.error('Upload fehlgeschlagen:', error); alert('Upload fehlgeschlagen'); } }; const handleCommercialRegDelete = async () => { if (!confirm('Handelsregisterauszug wirklich löschen?')) return; try { await uploadApi.deleteCommercialRegister(customer.id); onUpdate(); } catch (error) { console.error('Löschen fehlgeschlagen:', error); alert('Löschen fehlgeschlagen'); } }; const hasData = customer.taxNumber || customer.commercialRegisterNumber || customer.businessRegistrationPath || customer.commercialRegisterPath; if (!hasData && !canEdit) return null; return (
{customer.taxNumber && (
Steuernummer
{customer.taxNumber}
)} {customer.commercialRegisterNumber && (
Handelsregisternummer
{customer.commercialRegisterNumber}
)}
{/* Dokumente */}
{/* Gewerbeanmeldung */}

Gewerbeanmeldung

{customer.businessRegistrationPath ? (
Anzeigen Download {canEdit && ( <> )}
) : canEdit ? ( ) : (

Nicht vorhanden

)}
{/* Handelsregisterauszug */}

Handelsregisterauszug

{customer.commercialRegisterPath ? (
Anzeigen Download {canEdit && ( <> )}
) : canEdit ? ( ) : (

Nicht vorhanden

)}
); } // Tab Components function AddressesTab({ customerId, addresses, canEdit, onAdd, onEdit, }: { customerId: number; addresses: Address[]; canEdit: boolean; onAdd: () => void; onEdit: (address: Address) => void; }) { const queryClient = useQueryClient(); const deleteMutation = useMutation({ mutationFn: addressApi.delete, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); return (
{canEdit && (
)} {addresses.length > 0 ? (
{addresses.map((addr) => (
{addr.type === 'BILLING' ? 'Rechnung' : 'Liefer-/Meldeadresse'} {addr.isDefault && Standard}
{canEdit && (
)}

{addr.street} {addr.houseNumber}

{addr.postalCode} {addr.city}

{addr.country}

{(addr.ownerFirstName || addr.ownerLastName || addr.ownerCompany) && (
Eigentümer: {addr.ownerCompany && {addr.ownerCompany} – } {addr.ownerFirstName} {addr.ownerLastName} {addr.ownerPhone && · {addr.ownerPhone}}
)}
))}
) : (

Keine Adressen vorhanden.

)}
); } function BankCardsTab({ customerId, bankCards, canEdit, showInactive, onToggleInactive, onAdd, onEdit, }: { customerId: number; bankCards: BankCard[]; canEdit: boolean; showInactive: boolean; onToggleInactive: () => void; onAdd: () => void; onEdit: (card: BankCard) => void; }) { const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => bankCardApi.update(id, data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); const deleteMutation = useMutation({ mutationFn: bankCardApi.delete, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); const handleDocumentUpload = async (cardId: number, file: File) => { try { await uploadApi.uploadBankCardDocument(cardId, file); queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); } catch (error) { console.error('Upload fehlgeschlagen:', error); alert('Upload fehlgeschlagen'); } }; const handleDocumentDelete = async (cardId: number) => { if (!confirm('Dokument wirklich löschen?')) return; try { await uploadApi.deleteBankCardDocument(cardId); queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); } catch (error) { console.error('Löschen fehlgeschlagen:', error); alert('Löschen fehlgeschlagen'); } }; const filtered = showInactive ? bankCards : bankCards.filter((c) => c.isActive); return (
{canEdit && ( )}
{filtered.length > 0 ? (
{filtered.map((card) => (
{!card.isActive && Inaktiv} {card.expiryDate && new Date(card.expiryDate) < new Date() && ( Abgelaufen )}
{canEdit && (
{card.isActive ? ( ) : ( )}
)}

{card.accountHolder}

{card.iban}

{card.bic && (

BIC: {card.bic}

)} {card.bankName && (

{card.bankName}

)} {card.expiryDate && (

Gültig bis: {formatDate(card.expiryDate)}

)} {card.description && (

{card.description}

)} {/* Dokument-Upload Bereich */}
{card.documentPath ? (
Anzeigen Download {canEdit && ( <> handleDocumentUpload(card.id, file)} existingFile={card.documentPath} accept=".pdf" label="Ersetzen" disabled={!card.isActive} /> )}
) : ( canEdit && card.isActive && ( handleDocumentUpload(card.id, file)} accept=".pdf" label="PDF hochladen" /> ) )}
))}
) : (

Keine Bankkarten vorhanden.

)}
); } function DocumentsTab({ customerId, documents, canEdit, showInactive, onToggleInactive, onAdd, onEdit, }: { customerId: number; documents: IdentityDocument[]; canEdit: boolean; showInactive: boolean; onToggleInactive: () => void; onAdd: () => void; onEdit: (doc: IdentityDocument) => void; }) { const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => documentApi.update(id, data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); const deleteMutation = useMutation({ mutationFn: documentApi.delete, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); const handleDocumentUpload = async (docId: number, file: File) => { try { await uploadApi.uploadIdentityDocument(docId, file); queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); } catch (error) { console.error('Upload fehlgeschlagen:', error); alert('Upload fehlgeschlagen'); } }; const handleDocumentDelete = async (docId: number) => { if (!confirm('Dokument wirklich löschen?')) return; try { await uploadApi.deleteIdentityDocument(docId); queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); } catch (error) { console.error('Löschen fehlgeschlagen:', error); alert('Löschen fehlgeschlagen'); } }; const filtered = showInactive ? documents : documents.filter((d) => d.isActive); const docTypeLabels: Record = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges', }; return (
{canEdit && ( )}
{filtered.length > 0 ? (
{filtered.map((doc) => (
{docTypeLabels[doc.type]} {!doc.isActive && Inaktiv} {doc.expiryDate && new Date(doc.expiryDate) < new Date() && ( Abgelaufen )}
{canEdit && (
{doc.isActive ? ( ) : ( )}
)}

{doc.documentNumber}

{doc.issuingAuthority && (

Ausgestellt von: {doc.issuingAuthority}

)} {doc.expiryDate && (

Gültig bis: {new Date(doc.expiryDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}

)} {/* Führerschein-spezifische Anzeige */} {doc.type === 'DRIVERS_LICENSE' && doc.licenseClasses && (

Klassen: {doc.licenseClasses}

)} {doc.type === 'DRIVERS_LICENSE' && doc.licenseIssueDate && (

Klasse B seit: {new Date(doc.licenseIssueDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}

)} {/* Dokument-Upload Bereich */}
{doc.documentPath ? (
Anzeigen Download {canEdit && ( <> handleDocumentUpload(doc.id, file)} existingFile={doc.documentPath} accept=".pdf" label="Ersetzen" disabled={!doc.isActive} /> )}
) : ( canEdit && doc.isActive && ( handleDocumentUpload(doc.id, file)} accept=".pdf" label="PDF hochladen" /> ) )}
))}
) : (

Keine Ausweise vorhanden.

)}
); } function MetersTab({ customerId, meters, addresses, canEdit, showInactive, onToggleInactive, onAdd, onEdit, }: { customerId: number; meters: Meter[]; addresses: Address[]; canEdit: boolean; showInactive: boolean; onToggleInactive: () => void; onAdd: () => void; onEdit: (meter: Meter) => void; }) { const hasDeliveryAddress = addresses.some((a) => a.type === 'DELIVERY_RESIDENCE'); const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string } | null>(null); const [expandedMeter, setExpandedMeter] = useState(null); const [expandedContractsForMeter, setExpandedContractsForMeter] = useState>(new Set()); const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string; reading: any } | null>(null); const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => meterApi.update(id, data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); const [deleteError, setDeleteError] = useState(null); const deleteMutation = useMutation({ mutationFn: meterApi.delete, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); setDeleteError(null); }, onError: (err) => { setDeleteError(err instanceof Error ? err.message : 'Fehler beim Löschen'); }, }); const deleteReadingMutation = useMutation({ mutationFn: ({ meterId, readingId }: { meterId: number; readingId: number }) => meterApi.deleteReading(meterId, readingId), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); const [showWithoutContracts, setShowWithoutContracts] = useState(false); // Liefert true, wenn der Zähler mindestens einem Vertrag zugeordnet ist // (entweder als Hauptzähler oder über die Folgezähler-Kette). const hasAnyContract = (m: Meter) => (m.energyDetails?.some((ed) => ed.contract) ?? false) || (m.contractMeters?.some((cm) => cm.energyContractDetails?.contract) ?? false); // Aktiv-Filter (Default: nur aktive) und "Nur Zähler ohne Verträge"-Filter // wirken unabhängig: erst Aktiv, dann optional auf orphans einschränken. // - beide aus: alle aktiven Zähler // - nur "Inaktive": alle Zähler (aktiv + inaktiv) // - nur "ohne Verträge": aktive Zähler OHNE Vertrag // - beide an: alle Zähler ohne Vertrag (aktiv + inaktiv) const filtered = meters .filter((m) => showInactive ? true : m.isActive) .filter((m) => showWithoutContracts ? !hasAnyContract(m) : true); // Sort readings by date (newest first) const getSortedReadings = (readings: any[] | undefined) => { if (!readings) return []; return [...readings].sort((a, b) => new Date(b.readingDate).getTime() - new Date(a.readingDate).getTime() ); }; return (
{canEdit && ( )}
{canEdit && !hasDeliveryAddress && (
Es ist noch keine Lieferadresse hinterlegt. Bitte zunächst im Tab „Adressen" eine Lieferadresse anlegen, dann können hier Zähler erstellt werden.
)} {filtered.length > 0 ? (
{filtered.map((meter) => { const sortedReadings = getSortedReadings(meter.readings); const isExpanded = expandedMeter === meter.id; return (
{meter.type === 'ELECTRICITY' ? 'Strom' : 'Gas'} {meter.tariffModel === 'DUAL' && ( HT/NT )} {!meter.isActive && Inaktiv}
{canEdit && (
{meter.isActive && ( )} {meter.isActive ? ( ) : ( )}
)}

{meter.meterNumber}

{meter.address ? (

Lieferadresse: {meter.address.street} {meter.address.houseNumber},{' '} {meter.address.postalCode} {meter.address.city}

) : (

Lieferadresse: nicht zugeordnet – bitte über „Bearbeiten" nachpflegen

)} {meter.location && (

Standort: {meter.location}

)} {(() => { // Zugeordnete Verträge zusammenstellen: zum einen über // energyDetails (aktueller Hauptzähler), zum anderen über // contractMeters (Folgezähler-Kette). Dedupliziert auf // contractId, da ein Zähler über beide Wege auftauchen kann. const seen = new Set(); const linkedContracts: Array<{ id: number; contractNumber: string; status: string; type: string; providerName?: string; via: 'main' | 'chain' }> = []; for (const ed of meter.energyDetails || []) { if (ed.contract && !seen.has(ed.contract.id)) { seen.add(ed.contract.id); linkedContracts.push({ ...ed.contract, via: 'main' }); } } for (const cm of meter.contractMeters || []) { const ct = cm.energyContractDetails?.contract; if (ct && !seen.has(ct.id)) { seen.add(ct.id); linkedContracts.push({ ...ct, via: 'chain' }); } } if (linkedContracts.length === 0) return null; const isContractsExpanded = expandedContractsForMeter.has(meter.id); return (
{isContractsExpanded && (
{linkedContracts.map((ct) => ( {ct.contractNumber} {ct.providerName && ( – {ct.providerName} )} {ct.status} {ct.via === 'chain' && ( (über Folgezähler-Kette) )} ))}
)}
); })()} {sortedReadings.length > 0 && (

Zählerstände:

{sortedReadings.length > 3 && ( )}
{(isExpanded ? sortedReadings : sortedReadings.slice(0, 3)).map((reading) => (
{formatDate(reading.readingDate)}
{reading.valueNt !== undefined && reading.valueNt !== null ? ( <>HT: {reading.value.toLocaleString('de-DE')} / NT: {reading.valueNt.toLocaleString('de-DE')} {reading.unit} ) : ( <>{reading.value.toLocaleString('de-DE')} {reading.unit} )} {canEdit && (
)}
))}
)}
); })}
) : (

Keine Zähler vorhanden.

)} {showReadingModal && ( setShowReadingModal(null)} meterId={showReadingModal.meterId} meterType={showReadingModal.meterType} tariffModel={showReadingModal.tariffModel as any} customerId={customerId} /> )} {editingReading && ( setEditingReading(null)} meterId={editingReading.meterId} meterType={editingReading.meterType} tariffModel={editingReading.tariffModel as any} customerId={customerId} reading={editingReading.reading} /> )} {/* Fehler-Modal beim Löschen (z.B. Zähler noch an Vertrag) */} {deleteError && ( setDeleteError(null)} title="Zähler kann nicht gelöscht werden">

Der Zähler ist noch folgenden Verträgen zugeordnet und kann daher nicht gelöscht werden:

{deleteError.match(/[A-Z]+-[A-Z0-9]+/g)?.map((contractNumber) => ( setDeleteError(null)} className="flex items-center gap-2 p-3 bg-gray-50 border rounded-lg text-blue-600 hover:bg-blue-50 hover:border-blue-300 transition-colors" > {contractNumber} )) ?? (

{deleteError}

)}

Bitte entfernen Sie den Zähler zuerst aus den oben genannten Verträgen.

)}
); } function ContractsTab({ customerId, }: { customerId: number; }) { const { hasPermission } = useAuth(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [expandedContracts, setExpandedContracts] = useState>(new Set()); const [showStatusInfo, setShowStatusInfo] = useState(false); const [showDeactivated, setShowDeactivated] = useState(false); // Lade Vertragsbaum statt flacher Liste. showDeactivated ist Teil des // Query-Keys, damit der Toggle einen frischen Fetch auslöst. const { data: treeData, isLoading } = useQuery({ queryKey: ['contract-tree', customerId, showDeactivated], queryFn: () => contractApi.getTreeForCustomer(customerId, showDeactivated), }); const contractTree = treeData?.data || []; const deleteMutation = useMutation({ mutationFn: contractApi.delete, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['customers'] }); queryClient.invalidateQueries({ queryKey: ['contracts'] }); queryClient.invalidateQueries({ queryKey: ['contract-tree', customerId] }); }, onError: (error: any) => { alert(error?.message || 'Fehler beim Löschen des Vertrags'); }, }); const typeLabels: Record = { ELECTRICITY: 'Strom', GAS: 'Gas', DSL: 'DSL', FIBER: 'Glasfaser', MOBILE: 'Mobilfunk', TV: 'TV', CAR_INSURANCE: 'KFZ-Versicherung', }; const statusLabels: Record = { DRAFT: 'Entwurf', PENDING: 'Ausstehend', ACTIVE: 'Aktiv', CANCELLED: 'Gekündigt / Bestätigung abwarten', CANCELLED_CONFIRMED: 'Gekündigt / bestätigt', EXPIRED: 'Abgelaufen', DEACTIVATED: 'Deaktiviert', ONGOING: 'Läuft weiter (unbestimmt)', }; const statusVariants: Record = { ACTIVE: 'success', PENDING: 'warning', CANCELLED: 'warning', CANCELLED_CONFIRMED: 'danger', EXPIRED: 'danger', DRAFT: 'default', DEACTIVATED: 'default', ONGOING: 'success', }; const statusDescriptions = [ { status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' }, { status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' }, { status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' }, { status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' }, { status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' }, { status: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten', description: 'Kündigungsschreiben raus – Bestätigung des Anbieters steht noch aus', color: 'text-orange-600' }, { status: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt', description: 'Kündigung vom Anbieter bestätigt, Vertrag endet', color: 'text-red-600' }, { status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' }, ]; const toggleExpand = (contractId: number) => { setExpandedContracts(prev => { const next = new Set(prev); if (next.has(contractId)) { next.delete(contractId); } else { next.add(contractId); } return next; }); }; // Rekursive Rendering-Funktion für Vorgänger const renderPredecessors = (predecessors: ContractTreeNode[], depth: number): React.ReactNode => { return predecessors.map(node => (
{renderContractNode(node, depth)}
)); }; // Einzelnen Vertragsknoten rendern const renderContractNode = (node: ContractTreeNode, depth: number = 0): React.ReactNode => { const { contract, predecessors, hasHistory } = node; const isExpanded = expandedContracts.has(contract.id); const isPredecessor = depth > 0; return (
{/* Aufklapp-Button nur bei Wurzelknoten mit Historie */} {!isPredecessor && hasHistory ? ( ) : !isPredecessor ? (
// Platzhalter für Ausrichtung ) : null} {contract.contractNumber} {typeLabels[contract.type] || contract.type} {statusLabels[contract.status] || contract.status} {depth === 0 && !isPredecessor && ( )} {isPredecessor && ( (Vorgänger) )}
{hasPermission('contracts:update') && ( )} {hasPermission('contracts:delete') && ( )}
{(contract.providerName || contract.provider?.name) && (

{contract.providerName || contract.provider?.name} {(contract.tariffName || contract.tariff?.name) && ` - ${contract.tariffName || contract.tariff?.name}`}

)} {(() => { const typeInfo = getContractTypeInfo(contract as any); return typeInfo ? (

{typeInfo.label}: {typeInfo.value} {typeInfo.cardUser && ( <> · Karteninhaber: {typeInfo.cardUser} )} {typeInfo.network && ( <> · Netz: {typeInfo.network} )}

) : null; })()} {contract.startDate && (

Beginn: {formatDate(contract.startDate)} {contract.endDate && ` | Ende: ${formatDate(contract.endDate)}`}

)} {contract.cancellationConfirmationDate && (

Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}

)}
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */} {((depth === 0 && isExpanded) || depth > 0) && predecessors.length > 0 && (
{renderPredecessors(predecessors, depth + 1)}
)}
); }; if (isLoading) { return (
); } return (
{hasPermission('contracts:create') ? ( ) : }
{contractTree.length > 0 ? (
{contractTree.map(node => renderContractNode(node, 0))}
) : (

Keine Verträge vorhanden.

)} {/* Status Info Modal */} {showStatusInfo && (
setShowStatusInfo(false)} />

Vertragsstatus-Übersicht

{statusDescriptions.map(({ status, label, description, color }) => (
{label} {description}
))}
)}
); } // Passwort-Komplexität – muss zur Backend-Regel in // backend/src/utils/passwordGenerator.ts:validatePasswordComplexity passen. function passwordMeetsComplexity(pw: string): boolean { return ( pw.length >= 12 && /[a-z]/.test(pw) && /[A-Z]/.test(pw) && /[0-9]/.test(pw) && /[^A-Za-z0-9]/.test(pw) ); } // Live-Hinweis welche Komplexitäts-Anforderungen noch fehlen function PasswordComplexityHint({ password }: { password: string }) { const checks = [ { ok: password.length >= 12, label: '≥ 12 Zeichen' }, { ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' }, { ok: /[A-Z]/.test(password), label: 'Großbuchstabe' }, { ok: /[0-9]/.test(password), label: 'Ziffer' }, { ok: /[^A-Za-z0-9]/.test(password), label: 'Sonderzeichen' }, ]; return (
    {checks.map((c) => (
  • {c.ok ? '✓' : '○'} {c.label}
  • ))}
); } // Gespeichertes Passwort anzeigen function StoredPasswordDisplay({ customerId }: { customerId: number }) { const [showStoredPassword, setShowStoredPassword] = useState(false); const [storedPassword, setStoredPassword] = useState(null); const [isLoading, setIsLoading] = useState(false); const handleShowPassword = async () => { if (showStoredPassword) { setShowStoredPassword(false); return; } setIsLoading(true); try { const result = await customerApi.getPortalPassword(customerId); setStoredPassword(result.data?.password || null); setShowStoredPassword(true); } catch (error) { console.error('Fehler beim Laden des Passworts:', error); alert('Fehler beim Laden des Passworts'); } finally { setIsLoading(false); } }; return (

Passwort ist gesetzt

{showStoredPassword && storedPassword && ( {storedPassword} )} {showStoredPassword && !storedPassword && ( (Passwort nicht verfügbar) )}
); } // Portal Tab Component function PortalTab({ customerId, canEdit, }: { customerId: number; canEdit: boolean; }) { const queryClient = useQueryClient(); const [showPassword, setShowPassword] = useState(false); const [newPassword, setNewPassword] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); // Lokaler Eingabezustand für die Portal-E-Mail. Wenn wir den Input // direkt vom Server-State (`portal?.portalEmail`) speisen und auf // jedes Keystroke einen PUT feuern, lehnt der Backend-Validator // (Pentest 29.4) die Zwischenstände ("p", "po", "por") als // ungültige E-Mail mit 400 ab → Server-State bleibt unverändert, // Input rendert mit altem Wert → man konnte nur per Paste // tippen. Lokaler State + Commit on Blur löst das. const [emailDraft, setEmailDraft] = useState(null); // Lade Portal-Einstellungen const { data: portalData, isLoading: portalLoading } = useQuery({ queryKey: ['customer-portal', customerId], queryFn: () => customerApi.getPortalSettings(customerId), }); // Lade Vertreter-Liste const { data: representativesData, isLoading: repLoading } = useQuery({ queryKey: ['customer-representatives', customerId], queryFn: () => customerApi.getRepresentatives(customerId), }); // Portal-Einstellungen aktualisieren const updatePortalMutation = useMutation({ mutationFn: (data: { portalEnabled?: boolean; portalEmail?: string | null }) => customerApi.updatePortalSettings(customerId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] }); // Lokalen Draft verwerfen – kommt jetzt frisch vom Server. setEmailDraft(null); }, onError: (err: Error) => { toast.error(err.message || 'Speichern fehlgeschlagen'); }, }); // Passwort setzen const setPasswordMutation = useMutation({ mutationFn: (password: string) => customerApi.setPortalPassword(customerId, password), onSuccess: () => { setNewPassword(''); queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] }); toast.success('Passwort wurde gesetzt'); }, onError: (error: Error) => { toast.error(error.message); }, }); // Passwort generieren (16 Zeichen, komplex) – ins Input-Feld füllen const generatePasswordMutation = useMutation({ mutationFn: () => customerApi.generatePortalPassword(customerId), onSuccess: (res) => { const generated = res.data?.password || ''; setNewPassword(generated); setShowPassword(true); toast.success('Komplexes Passwort generiert – jetzt „Setzen" klicken.'); }, onError: (error: Error) => { toast.error(error.message); }, }); // Zugangsdaten per E-Mail an den Kunden senden const sendCredentialsMutation = useMutation({ mutationFn: () => customerApi.sendPortalCredentials(customerId), onSuccess: (res) => { toast.success(res.message || 'Zugangsdaten gesendet'); }, onError: (error: Error) => { toast.error(error.message); }, }); // Vertreter hinzufügen const addRepMutation = useMutation({ mutationFn: (representativeId: number) => customerApi.addRepresentative(customerId, representativeId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer-representatives', customerId] }); setSearchTerm(''); setSearchResults([]); }, onError: (error: Error) => { alert(error.message); }, }); // Vertreter entfernen const removeRepMutation = useMutation({ mutationFn: (representativeId: number) => customerApi.removeRepresentative(customerId, representativeId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer-representatives', customerId] }); }, }); // Vertreter-Suche const handleSearch = async () => { if (searchTerm.length < 2) return; setIsSearching(true); try { const result = await customerApi.searchForRepresentative(customerId, searchTerm); setSearchResults(result.data || []); } catch (error) { console.error('Suche fehlgeschlagen:', error); } finally { setIsSearching(false); } }; if (portalLoading || repLoading) { return
Laden...
; } const portal = portalData?.data; const representatives = representativesData?.data || []; return (
{/* Portal-Einstellungen */}

Portal-Zugang

{/* Portal aktiviert */} {/* Portal E-Mail */}
setEmailDraft(e.target.value)} onBlur={() => { if (emailDraft === null) return; const trimmed = emailDraft.trim(); // Nur committen wenn sich was geändert hat – sonst // hauen wir bei jedem Tab-Wechsel einen sinnlosen // PUT raus. if (trimmed === (portal?.portalEmail || '')) { setEmailDraft(null); return; } updatePortalMutation.mutate({ portalEmail: trimmed || null }); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); (e.target as HTMLInputElement).blur(); } if (e.key === 'Escape') { setEmailDraft(null); (e.target as HTMLInputElement).blur(); } }} placeholder="portal@example.com" disabled={!canEdit || !portal?.portalEnabled} className="flex-1" />

Diese E-Mail wird für den Login ins Kundenportal verwendet. Speichern erfolgt automatisch beim Verlassen des Felds (oder Enter).

{/* Passwort setzen */} {portal?.portalEnabled && (
setNewPassword(e.target.value)} placeholder="Mind. 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen" disabled={!canEdit} />
{/* Komplexitäts-Hinweise: zeigt live welche Anforderungen erfüllt sind */} {newPassword.length > 0 && !passwordMeetsComplexity(newPassword) && ( )} {portal?.hasPassword && ( <>
)}
)} {/* Letzte Anmeldung */} {portal?.portalLastLogin && (

Letzte Anmeldung: {new Date(portal.portalLastLogin).toLocaleString('de-DE')}

)}
{/* Vertreter-Verwaltung */}

Vertreter (können Verträge einsehen)

Hier können Sie anderen Kunden erlauben, die Verträge dieses Kunden einzusehen. Beispiel: Der Sohn kann die Verträge seiner Mutter einsehen.

{/* Vertreter-Suche */} {canEdit && (
setSearchTerm(e.target.value)} placeholder="Kunden suchen (Name, Kundennummer)..." onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="flex-1" />

Vertreter ohne Portal-Zugang können bereits hinterlegt werden – der Zugriff wird erst wirksam, sobald das Portal aktiviert ist.

{/* Suchergebnisse */} {searchResults.length > 0 && (
{searchResults.map((customer) => (

{customer.companyName || `${customer.firstName} ${customer.lastName}`} {!customer.portalEnabled && ( Portal inaktiv )}

{customer.customerNumber}

))}
)}
)} {/* Aktuelle Vertreter */} {representatives.length > 0 ? (
{representatives.map((rep: CustomerRepresentative) => (

{rep.representative?.companyName || `${rep.representative?.firstName} ${rep.representative?.lastName}`} {rep.representative && rep.representative.portalEnabled === false && ( Portal inaktiv )}

{rep.representative?.customerNumber}

{canEdit && ( )}
))}
) : (

Keine Vertreter konfiguriert.

)}
); } // Modal Components function AddressModal({ isOpen, onClose, customerId, address, }: { isOpen: boolean; onClose: () => void; customerId: number; address?: Address | null; }) { const queryClient = useQueryClient(); const isEditing = !!address; const getInitialFormData = () => ({ type: address?.type || 'DELIVERY_RESIDENCE' as const, street: address?.street || '', houseNumber: address?.houseNumber || '', postalCode: address?.postalCode || '', city: address?.city || '', country: address?.country || 'Deutschland', isDefault: address?.isDefault || false, maloIdElectricity: address?.maloIdElectricity || '', maloIdGas: address?.maloIdGas || '', ownerCompany: address?.ownerCompany || '', ownerFirstName: address?.ownerFirstName || '', ownerLastName: address?.ownerLastName || '', ownerStreet: address?.ownerStreet || '', ownerHouseNumber: address?.ownerHouseNumber || '', ownerPostalCode: address?.ownerPostalCode || '', ownerCity: address?.ownerCity || '', ownerPhone: address?.ownerPhone || '', ownerMobile: address?.ownerMobile || '', ownerEmail: address?.ownerEmail || '', }); const [formData, setFormData] = useState(getInitialFormData); const createMutation = useMutation({ mutationFn: (data: typeof formData) => addressApi.create(customerId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); setFormData(getInitialFormData()); }, }); const updateMutation = useMutation({ mutationFn: (data: typeof formData) => addressApi.update(address!.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (isEditing) { updateMutation.mutate(formData); } else { createMutation.mutate(formData); } }; const isPending = createMutation.isPending || updateMutation.isPending; // Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren. // Guard `if (!isOpen)`: Modal bleibt dauerhaft gemountet – ohne Reset beim // Öffnen blieben nach „Abbrechen" die vorher getippten Daten stehen. // Deps nur isOpen + Adress-ID → kein Reset bei jedem Tastendruck. useEffect(() => { if (!isOpen) return; setFormData(getInitialFormData()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, address?.id]); return (
setFormData({ ...formData, street: e.target.value })} required />
setFormData({ ...formData, houseNumber: e.target.value })} required />
setFormData({ ...formData, postalCode: e.target.value })} required />
setFormData({ ...formData, city: e.target.value })} required />
setFormData({ ...formData, country: e.target.value })} /> {/* Marktlokations-IDs (nur Lieferadresse) – gehören zur Adresse, nicht zum Vertrag; im Vertrag werden sie je Sparte nur noch angezeigt. */} {formData.type === 'DELIVERY_RESIDENCE' && (

Marktlokations-IDs (MaLo)

Je Sparte getrennt. Wird im Strom-/Gasvertrag automatisch aus dieser Lieferadresse angezeigt.

setFormData({ ...formData, maloIdElectricity: e.target.value })} placeholder="z.B. 11XXXXXXXXXXX" /> setFormData({ ...formData, maloIdGas: e.target.value })} placeholder="z.B. 11XXXXXXXXXXX" />
)} {/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */} {formData.type === 'DELIVERY_RESIDENCE' && (

Eigentümer

Nur ausfüllen wenn der Kunde nicht selbst Eigentümer ist (z.B. Mietwohnung).

setFormData({ ...formData, ownerCompany: e.target.value })} placeholder="z.B. Wohnungsbaugesellschaft" />
setFormData({ ...formData, ownerFirstName: e.target.value })} /> setFormData({ ...formData, ownerLastName: e.target.value })} />
setFormData({ ...formData, ownerStreet: e.target.value })} />
setFormData({ ...formData, ownerHouseNumber: e.target.value })} />
setFormData({ ...formData, ownerPostalCode: e.target.value })} />
setFormData({ ...formData, ownerCity: e.target.value })} />
setFormData({ ...formData, ownerPhone: e.target.value })} /> setFormData({ ...formData, ownerMobile: e.target.value })} /> setFormData({ ...formData, ownerEmail: e.target.value })} type="email" />
)}
); } function BankCardModal({ isOpen, onClose, customerId, bankCard, }: { isOpen: boolean; onClose: () => void; customerId: number; bankCard?: BankCard | null; }) { const queryClient = useQueryClient(); const isEditing = !!bankCard; const getInitialFormData = () => ({ accountHolder: bankCard?.accountHolder || '', iban: bankCard?.iban || '', cardNumber: bankCard?.cardNumber || '', bic: bankCard?.bic || '', bankName: bankCard?.bankName || '', expiryDate: bankCard?.expiryDate ? new Date(bankCard.expiryDate).toISOString().split('T')[0] : '', description: bankCard?.description || '', isActive: bankCard?.isActive ?? true, }); const [formData, setFormData] = useState(getInitialFormData); const [ibanLookupLoading, setIbanLookupLoading] = useState(false); const [showIbanRequired, setShowIbanRequired] = useState(false); // BIC + Bankname aus der IBAN ableiten (offline über Bundesbank-BLZ im // Backend). Ist das IBAN-Feld leer, kommt eine OK-Messagebox statt einer // Anfrage. Gleichzeitig prüft der Server die IBAN-Prüfziffer (Tippfehler). const handleIbanLookup = async () => { const iban = formData.iban.replace(/\s+/g, '').trim(); if (!iban) { setShowIbanRequired(true); return; } setIbanLookupLoading(true); try { const res = await bankCardApi.ibanLookup(iban); const d = res.data; if (!d) { toast.error('IBAN-Prüfung fehlgeschlagen.'); return; } if (!d.valid) { toast.error('Die IBAN ist ungültig – bitte Eingabe/Prüfziffer kontrollieren.'); return; } if (d.bic || d.bankName) { setFormData((prev) => ({ ...prev, bic: d.bic || prev.bic, bankName: d.bankName || prev.bankName, })); toast.success('IBAN gültig – BIC und Bank übernommen.'); } else { toast('IBAN ist gültig, aber es wurden keine Bankdaten gefunden (nur deutsche IBANs werden aufgelöst).', { icon: 'ℹ️', }); } } catch (err: any) { toast.error(err?.response?.data?.error || 'Fehler beim IBAN-Nachschlagen.'); } finally { setIbanLookupLoading(false); } }; const createMutation = useMutation({ mutationFn: (data: any) => bankCardApi.create(customerId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); setFormData({ accountHolder: '', iban: '', cardNumber: '', bic: '', bankName: '', expiryDate: '', description: '', isActive: true }); }, }); const updateMutation = useMutation({ mutationFn: (data: any) => bankCardApi.update(bankCard!.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const data = { ...formData, expiryDate: formData.expiryDate ? new Date(formData.expiryDate) : undefined, }; if (isEditing) { updateMutation.mutate(data); } else { createMutation.mutate(data); } }; const isPending = createMutation.isPending || updateMutation.isPending; // Formular zurücksetzen, sobald die Modal geöffnet wird (Neuanlage: leer, // Bearbeiten: Kartendaten). Nötig, weil beide Modal-Instanzen dauerhaft // gemountet bleiben und nur per isOpen umgeschaltet werden – ohne diesen // Reset blieben nach „Abbrechen" die vorher getippten Daten stehen. // Läuft NICHT bei jedem Tastendruck (Deps nur isOpen + Karten-ID), sonst // würde jede Eingabe wieder auf den DB-Wert zurückspringen. useEffect(() => { if (!isOpen) return; setFormData(getInitialFormData()); setIbanLookupLoading(false); setShowIbanRequired(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, bankCard?.id]); return ( <>
setFormData({ ...formData, accountHolder: e.target.value })} required />
setFormData({ ...formData, iban: e.target.value })} required />
setFormData({ ...formData, cardNumber: e.target.value })} /> setFormData({ ...formData, bic: e.target.value })} /> setFormData({ ...formData, bankName: e.target.value })} /> setFormData({ ...formData, expiryDate: e.target.value })} onClear={() => setFormData({ ...formData, expiryDate: '' })} />