Files
opencrm/frontend/src/pages/customers/CustomerDetail.tsx
T
duffyduck 6175421a4c Anrede-Verhältnis Du/Sie pro Kunde + Geburtstagsgruß respektiert Anrede
Schema:
- Customer.useInformalAddress: Boolean (Default: false = Sie)
- Auch bei Firmenkunden verfügbar (Chef kann man auch duzen)

Frontend:
- Neues Pflichtfeld "Anrede per" (Du/Sie) im Kunden-Formular
- Anzeige als Badge in CustomerDetail-Stammdaten

Geburtstagsgruß im Portal:
- Bei Du: "Herzlichen Glückwunsch, Max! Alles Gute zu deinem 42. Geburtstag!"
- Bei Sie: "Herzlichen Glückwunsch, Herr Müller! Alles Gute zu Ihrem 42. Geburtstag!"
- Konsistent auch bei nachträglichen Glückwünschen (hattest/hatten, bist/sind etc.)
- Backend liefert firstName, lastName, salutation und useInformalAddress

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:27:23 +02:00

4245 lines
153 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 { 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 { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email';
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 } from 'lucide-react';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
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);
// 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 [showInactive, setShowInactive] = useState(false);
const [editingBankCard, setEditingBankCard] = useState<BankCard | null>(null);
const [editingDocument, setEditingDocument] = useState<IdentityDocument | null>(null);
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const [editingMeter, setEditingMeter] = useState<Meter | null>(null);
const [editingStressfreiEmail, setEditingStressfreiEmail] = useState<StressfreiEmail | null>(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 <div className="text-center py-8">Laden...</div>;
}
if (!customer?.data) {
return <div className="text-center py-8 text-red-600">Kunde nicht gefunden</div>;
}
const c = customer.data;
// Gesperrter Inhalt für Tabs ohne Einwilligung
const blockedContent = (
<ConsentBlockedContent onGoToConsentsTab={() => handleTabChange('consents')} />
);
const tabs = [
{
id: 'addresses',
label: 'Adressen',
content: (
<AddressesTab
customerId={customerId}
addresses={c.addresses || []}
canEdit={hasPermission('customers:update')}
onAdd={() => setShowAddressModal(true)}
onEdit={(addr) => setEditingAddress(addr)}
/>
),
},
{
id: 'bankcards',
label: 'Bankkarten',
content: hasConsentApproval ? (
<BankCardsTab
customerId={customerId}
bankCards={c.bankCards || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowBankCardModal(true)}
onEdit={(card) => setEditingBankCard(card)}
/>
) : blockedContent,
},
{
id: 'documents',
label: 'Ausweise',
content: hasConsentApproval ? (
<DocumentsTab
customerId={customerId}
documents={c.identityDocuments || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowDocumentModal(true)}
onEdit={(doc) => setEditingDocument(doc)}
/>
) : blockedContent,
},
{
id: 'meters',
label: 'Zähler',
content: hasConsentApproval ? (
<MetersTab
customerId={customerId}
meters={c.meters || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowMeterModal(true)}
onEdit={(meter) => setEditingMeter(meter)}
/>
) : blockedContent,
},
...(!isCustomerPortal ? [{
id: 'stressfrei',
label: 'Stressfrei-Wechseln',
content: (
<StressfreiEmailsTab
customerId={customerId}
emails={c.stressfreiEmails || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowStressfreiEmailModal(true)}
onEdit={(email) => setEditingStressfreiEmail(email)}
/>
),
}] : []),
{
id: 'emails',
label: 'E-Mail-Postfach',
content: hasConsentApproval ? (
<EmailClientTab customerId={customerId} />
) : blockedContent,
},
{
id: 'contracts',
label: 'Verträge',
content: hasConsentApproval ? (
<ContractsTab
customerId={customerId}
/>
) : blockedContent,
},
...(hasPermission('customers:update') ? [{
id: 'portal',
label: 'Portal',
content: (
<PortalTab
customerId={customerId}
canEdit={hasPermission('customers:update')}
/>
),
}] : []),
...(hasPermission('customers:read') && !isCustomerPortal ? [{
id: 'consents',
label: 'Einwilligungen / Datenschutz',
content: (
<ConsentTab
customerId={customerId}
canEdit={false}
customerEmail={c.email || undefined}
customer={c}
onUpdate={() => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['consent-status', customerId] });
}}
/>
),
}] : []),
];
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-2xl font-bold">
{c.type === 'BUSINESS' && c.companyName
? c.companyName
: `${c.firstName} ${c.lastName}`}
</h1>
<p className="text-gray-500 font-mono flex items-center gap-1">
{c.customerNumber}
<CopyButton value={c.customerNumber} />
</p>
</div>
</div>
<div className="flex gap-2">
{hasPermission('customers:update') && (
<Link to={`/customers/${id}/edit`} state={pushHistory(location.pathname + location.search, (location as any).state)}>
<Button variant="secondary">
<Edit className="w-4 h-4 mr-2" />
Bearbeiten
</Button>
</Link>
)}
{hasPermission('customers:delete') && (
<Button
variant="danger"
onClick={() => {
if (confirm('Kunde wirklich löschen?')) {
deleteMutation.mutate();
}
}}
>
<Trash2 className="w-4 h-4 mr-2" />
Löschen
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<Card title="Stammdaten" className="lg:col-span-2">
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-gray-500">Typ</dt>
<dd>
<Badge variant={c.type === 'BUSINESS' ? 'info' : 'default'}>
{c.type === 'BUSINESS' ? 'Geschäftskunde' : 'Privatkunde'}
</Badge>
</dd>
</div>
{c.salutation && (
<div>
<dt className="text-sm text-gray-500">Anrede</dt>
<dd className="flex items-center gap-1">
{c.salutation}
<CopyButton value={c.salutation} />
</dd>
</div>
)}
<div>
<dt className="text-sm text-gray-500">Anrede per</dt>
<dd>
<Badge variant={c.useInformalAddress ? 'info' : 'default'}>
{c.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Vorname</dt>
<dd className="flex items-center gap-1">
{c.firstName}
<CopyButton value={c.firstName} />
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Nachname</dt>
<dd className="flex items-center gap-1">
{c.lastName}
<CopyButton value={c.lastName} />
</dd>
</div>
{c.companyName && (
<div className="col-span-2">
<dt className="text-sm text-gray-500">Firma</dt>
<dd className="flex items-center gap-1">
{c.companyName}
<CopyButton value={c.companyName} />
</dd>
</div>
)}
{c.foundingDate && (
<div>
<dt className="text-sm text-gray-500">Gründungsdatum</dt>
<dd className="flex items-center gap-1">
{new Date(c.foundingDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
<CopyButton value={new Date(c.foundingDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} />
</dd>
</div>
)}
{c.birthDate && (
<div>
<dt className="text-sm text-gray-500">Geburtsdatum</dt>
<dd className="flex items-center gap-1">
{new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
<CopyButton value={new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} />
</dd>
</div>
)}
{c.birthPlace && (
<div>
<dt className="text-sm text-gray-500">Geburtsort</dt>
<dd className="flex items-center gap-1">
{c.birthPlace}
<CopyButton value={c.birthPlace} />
</dd>
</div>
)}
</dl>
</Card>
<Card title="Kontakt">
<dl className="space-y-3">
{c.email && (
<div>
<dt className="text-sm text-gray-500">E-Mail</dt>
<dd className="flex items-center gap-1">
<a href={`mailto:${c.email}`} className="text-blue-600 hover:underline">
{c.email}
</a>
<CopyButton value={c.email} />
</dd>
</div>
)}
{c.phone && (
<div>
<dt className="text-sm text-gray-500">Telefon</dt>
<dd className="flex items-center gap-1">
<a href={`tel:${c.phone}`} className="text-blue-600 hover:underline">
{c.phone}
</a>
<CopyButton value={c.phone} />
</dd>
</div>
)}
{c.mobile && (
<div>
<dt className="text-sm text-gray-500">Mobil</dt>
<dd className="flex items-center gap-1">
<a href={`tel:${c.mobile}`} className="text-blue-600 hover:underline">
{c.mobile}
</a>
<CopyButton value={c.mobile} />
</dd>
</div>
)}
</dl>
</Card>
</div>
{c.type === 'BUSINESS' && (
<BusinessDataCard
customer={c}
canEdit={hasPermission('customers:update')}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['customer', customerId] })}
/>
)}
{c.notes && (
<Card title="Notizen" className="mb-6">
<p className="whitespace-pre-wrap">{c.notes}</p>
</Card>
)}
<Card>
<Tabs tabs={tabs} defaultTab={defaultTab} activeTab={activeTab} onTabChange={handleTabChange} />
</Card>
<AddressModal
isOpen={showAddressModal}
onClose={() => setShowAddressModal(false)}
customerId={customerId}
/>
<AddressModal
isOpen={!!editingAddress}
onClose={() => setEditingAddress(null)}
customerId={customerId}
address={editingAddress}
/>
<BankCardModal
isOpen={showBankCardModal}
onClose={() => setShowBankCardModal(false)}
customerId={customerId}
/>
<BankCardModal
isOpen={!!editingBankCard}
onClose={() => setEditingBankCard(null)}
customerId={customerId}
bankCard={editingBankCard}
/>
<DocumentModal
isOpen={showDocumentModal}
onClose={() => setShowDocumentModal(false)}
customerId={customerId}
/>
<DocumentModal
isOpen={!!editingDocument}
onClose={() => setEditingDocument(null)}
customerId={customerId}
document={editingDocument}
/>
<MeterModal
isOpen={showMeterModal}
onClose={() => setShowMeterModal(false)}
customerId={customerId}
/>
<MeterModal
isOpen={!!editingMeter}
onClose={() => setEditingMeter(null)}
customerId={customerId}
meter={editingMeter}
/>
<StressfreiEmailModal
isOpen={showStressfreiEmailModal}
onClose={() => setShowStressfreiEmailModal(false)}
customerId={customerId}
customerEmail={customer?.data?.email}
/>
<StressfreiEmailModal
isOpen={!!editingStressfreiEmail}
onClose={() => setEditingStressfreiEmail(null)}
customerId={customerId}
email={editingStressfreiEmail}
customerEmail={customer?.data?.email}
/>
</div>
);
}
// 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 (
<Card title="Geschäftsdaten" className="mb-6">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
{customer.taxNumber && (
<div>
<dt className="text-sm text-gray-500">Steuernummer</dt>
<dd className="flex items-center gap-1">
{customer.taxNumber}
<CopyButton value={customer.taxNumber} />
</dd>
</div>
)}
{customer.commercialRegisterNumber && (
<div>
<dt className="text-sm text-gray-500">Handelsregisternummer</dt>
<dd className="flex items-center gap-1">
{customer.commercialRegisterNumber}
<CopyButton value={customer.commercialRegisterNumber} />
</dd>
</div>
)}
</dl>
{/* Dokumente */}
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Gewerbeanmeldung */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Gewerbeanmeldung</h4>
{customer.businessRegistrationPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${customer.businessRegistrationPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${customer.businessRegistrationPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
{canEdit && (
<>
<FileUpload
onUpload={handleBusinessRegUpload}
existingFile={customer.businessRegistrationPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={handleBusinessRegDelete}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</>
)}
</div>
) : canEdit ? (
<FileUpload
onUpload={handleBusinessRegUpload}
accept=".pdf"
label="PDF hochladen"
/>
) : (
<p className="text-sm text-gray-400">Nicht vorhanden</p>
)}
</div>
{/* Handelsregisterauszug */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Handelsregisterauszug</h4>
{customer.commercialRegisterPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${customer.commercialRegisterPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${customer.commercialRegisterPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
{canEdit && (
<>
<FileUpload
onUpload={handleCommercialRegUpload}
existingFile={customer.commercialRegisterPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={handleCommercialRegDelete}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</>
)}
</div>
) : canEdit ? (
<FileUpload
onUpload={handleCommercialRegUpload}
accept=".pdf"
label="PDF hochladen"
/>
) : (
<p className="text-sm text-gray-400">Nicht vorhanden</p>
)}
</div>
</div>
</Card>
);
}
// 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 (
<div>
{canEdit && (
<div className="mb-4">
<Button size="sm" onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
Adresse hinzufügen
</Button>
</div>
)}
{addresses.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{addresses.map((addr) => (
<div key={addr.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 mb-2">
<MapPin className="w-4 h-4 text-gray-400" />
<Badge variant={addr.type === 'BILLING' ? 'info' : 'default'}>
{addr.type === 'BILLING' ? 'Rechnung' : 'Liefer-/Meldeadresse'}
</Badge>
{addr.isDefault && <Badge variant="success">Standard</Badge>}
</div>
{canEdit && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(addr)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Adresse wirklich löschen?')) {
deleteMutation.mutate(addr.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</div>
<CopyableBlock
values={[
`${addr.street} ${addr.houseNumber}`,
`${addr.postalCode} ${addr.city}`,
addr.country
]}
>
<p>
{addr.street} {addr.houseNumber}
</p>
<p>
{addr.postalCode} {addr.city}
</p>
<p className="text-gray-500">{addr.country}</p>
</CopyableBlock>
{(addr.ownerFirstName || addr.ownerLastName || addr.ownerCompany) && (
<div className="mt-2 pt-2 border-t text-xs text-gray-500">
<span className="font-medium">Eigentümer: </span>
{addr.ownerCompany && <span>{addr.ownerCompany} </span>}
{addr.ownerFirstName} {addr.ownerLastName}
{addr.ownerPhone && <span> · {addr.ownerPhone}</span>}
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500">Keine Adressen vorhanden.</p>
)}
</div>
);
}
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<BankCard> }) =>
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 (
<div>
<div className="flex items-center gap-4 mb-4">
{canEdit && (
<Button size="sm" onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
Bankkarte hinzufügen
</Button>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={onToggleInactive}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{filtered.length > 0 ? (
<div className="space-y-4">
{filtered.map((card) => (
<div
key={card.id}
className={`border rounded-lg p-4 ${!card.isActive ? 'opacity-50 bg-gray-50' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 mb-2">
<CreditCard className="w-4 h-4 text-gray-400" />
{!card.isActive && <Badge variant="danger">Inaktiv</Badge>}
{card.expiryDate && new Date(card.expiryDate) < new Date() && (
<Badge variant="warning">Abgelaufen</Badge>
)}
</div>
{canEdit && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(card)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
{card.isActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Bankkarte deaktivieren?')) {
updateMutation.mutate({ id: card.id, data: { isActive: false } });
}
}}
title="Deaktivieren"
>
<EyeOff className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Bankkarte wieder aktivieren?')) {
updateMutation.mutate({ id: card.id, data: { isActive: true } });
}
}}
title="Aktivieren"
>
<Eye className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Bankkarte wirklich löschen?')) {
deleteMutation.mutate(card.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</div>
<p className="font-medium flex items-center gap-1">
{card.accountHolder}
<CopyButton value={card.accountHolder} />
</p>
<p className="font-mono flex items-center gap-1">
{card.iban}
<CopyButton value={card.iban} />
</p>
{card.bic && (
<p className="text-sm text-gray-500 flex items-center gap-1">
BIC: {card.bic}
<CopyButton value={card.bic} />
</p>
)}
{card.bankName && (
<p className="text-sm text-gray-500 flex items-center gap-1">
{card.bankName}
<CopyButton value={card.bankName} />
</p>
)}
{card.expiryDate && (
<p className="text-sm text-gray-500">
Gültig bis: {formatDate(card.expiryDate)}
</p>
)}
{/* Dokument-Upload Bereich */}
<div className="mt-3 pt-3 border-t">
{card.documentPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${card.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${card.documentPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
{canEdit && (
<>
<FileUpload
onUpload={(file) => handleDocumentUpload(card.id, file)}
existingFile={card.documentPath}
accept=".pdf"
label="Ersetzen"
disabled={!card.isActive}
/>
<button
onClick={() => handleDocumentDelete(card.id)}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
title="Dokument löschen"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</>
)}
</div>
) : (
canEdit && card.isActive && (
<FileUpload
onUpload={(file) => handleDocumentUpload(card.id, file)}
accept=".pdf"
label="PDF hochladen"
/>
)
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">Keine Bankkarten vorhanden.</p>
)}
</div>
);
}
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<IdentityDocument> }) =>
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<string, string> = {
ID_CARD: 'Personalausweis',
PASSPORT: 'Reisepass',
DRIVERS_LICENSE: 'Führerschein',
OTHER: 'Sonstiges',
};
return (
<div>
<div className="flex items-center gap-4 mb-4">
{canEdit && (
<Button size="sm" onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
Ausweis hinzufügen
</Button>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={onToggleInactive}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{filtered.length > 0 ? (
<div className="space-y-4">
{filtered.map((doc) => (
<div
key={doc.id}
className={`border rounded-lg p-4 ${!doc.isActive ? 'opacity-50 bg-gray-50' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-gray-400" />
<Badge>{docTypeLabels[doc.type]}</Badge>
{!doc.isActive && <Badge variant="danger">Inaktiv</Badge>}
{doc.expiryDate && new Date(doc.expiryDate) < new Date() && (
<Badge variant="warning">Abgelaufen</Badge>
)}
</div>
{canEdit && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(doc)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
{doc.isActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Ausweis deaktivieren?')) {
updateMutation.mutate({ id: doc.id, data: { isActive: false } });
}
}}
title="Deaktivieren"
>
<EyeOff className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Ausweis wieder aktivieren?')) {
updateMutation.mutate({ id: doc.id, data: { isActive: true } });
}
}}
title="Aktivieren"
>
<Eye className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Ausweis wirklich löschen?')) {
deleteMutation.mutate(doc.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</div>
<p className="font-mono flex items-center gap-1">
{doc.documentNumber}
<CopyButton value={doc.documentNumber} />
</p>
{doc.issuingAuthority && (
<p className="text-sm text-gray-500 flex items-center gap-1">
Ausgestellt von: {doc.issuingAuthority}
<CopyButton value={doc.issuingAuthority} />
</p>
)}
{doc.expiryDate && (
<p className="text-sm text-gray-500">
Gültig bis: {new Date(doc.expiryDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
)}
{/* Führerschein-spezifische Anzeige */}
{doc.type === 'DRIVERS_LICENSE' && doc.licenseClasses && (
<p className="text-sm text-gray-500 flex items-center gap-1">
Klassen: {doc.licenseClasses}
<CopyButton value={doc.licenseClasses} />
</p>
)}
{doc.type === 'DRIVERS_LICENSE' && doc.licenseIssueDate && (
<p className="text-sm text-gray-500">
Klasse B seit: {new Date(doc.licenseIssueDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
)}
{/* Dokument-Upload Bereich */}
<div className="mt-3 pt-3 border-t">
{doc.documentPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${doc.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${doc.documentPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
{canEdit && (
<>
<FileUpload
onUpload={(file) => handleDocumentUpload(doc.id, file)}
existingFile={doc.documentPath}
accept=".pdf"
label="Ersetzen"
disabled={!doc.isActive}
/>
<button
onClick={() => handleDocumentDelete(doc.id)}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
title="Dokument löschen"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</>
)}
</div>
) : (
canEdit && doc.isActive && (
<FileUpload
onUpload={(file) => handleDocumentUpload(doc.id, file)}
accept=".pdf"
label="PDF hochladen"
/>
)
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">Keine Ausweise vorhanden.</p>
)}
</div>
);
}
function MetersTab({
customerId,
meters,
canEdit,
showInactive,
onToggleInactive,
onAdd,
onEdit,
}: {
customerId: number;
meters: Meter[];
canEdit: boolean;
showInactive: boolean;
onToggleInactive: () => void;
onAdd: () => void;
onEdit: (meter: Meter) => void;
}) {
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string } | null>(null);
const [expandedMeter, setExpandedMeter] = useState<number | null>(null);
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<Meter> }) =>
meterApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const [deleteError, setDeleteError] = useState<string | null>(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 filtered = showInactive ? meters : meters.filter((m) => m.isActive);
// 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 (
<div>
<div className="flex items-center gap-4 mb-4">
{canEdit && (
<Button size="sm" onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
Zähler hinzufügen
</Button>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={onToggleInactive}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{filtered.length > 0 ? (
<div className="space-y-4">
{filtered.map((meter) => {
const sortedReadings = getSortedReadings(meter.readings);
const isExpanded = expandedMeter === meter.id;
return (
<div
key={meter.id}
className={`border rounded-lg p-4 ${!meter.isActive ? 'opacity-50 bg-gray-50' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 mb-2">
<Gauge className="w-4 h-4 text-gray-400" />
<Badge variant={meter.type === 'ELECTRICITY' ? 'warning' : 'info'}>
{meter.type === 'ELECTRICITY' ? 'Strom' : 'Gas'}
</Badge>
{meter.tariffModel === 'DUAL' && (
<Badge variant="default">HT/NT</Badge>
)}
{!meter.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{canEdit && (
<div className="flex gap-1">
{meter.isActive && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel })}
title="Zählerstand hinzufügen"
>
<Plus className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(meter)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
{meter.isActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Zähler deaktivieren?')) {
updateMutation.mutate({ id: meter.id, data: { isActive: false } });
}
}}
title="Deaktivieren"
>
<EyeOff className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Zähler wieder aktivieren?')) {
updateMutation.mutate({ id: meter.id, data: { isActive: true } });
}
}}
title="Aktivieren"
>
<Eye className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Zähler wirklich löschen? Alle Zählerstände werden ebenfalls gelöscht.')) {
deleteMutation.mutate(meter.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</div>
<p className="font-mono text-lg flex items-center gap-1">
{meter.meterNumber}
<CopyButton value={meter.meterNumber} />
</p>
{meter.location && (
<p className="text-sm text-gray-500 flex items-center gap-1">
Standort: {meter.location}
<CopyButton value={meter.location} />
</p>
)}
{sortedReadings.length > 0 && (
<div className="mt-3 pt-3 border-t">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">Zählerstände:</p>
{sortedReadings.length > 3 && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedMeter(isExpanded ? null : meter.id)}
>
{isExpanded ? 'Weniger anzeigen' : `Alle ${sortedReadings.length} anzeigen`}
</Button>
)}
</div>
<div className="space-y-1">
{(isExpanded ? sortedReadings : sortedReadings.slice(0, 3)).map((reading) => (
<div key={reading.id} className="flex justify-between items-center text-sm group">
<span className="text-gray-500 flex items-center gap-1">
{formatDate(reading.readingDate)}
<CopyButton value={formatDate(reading.readingDate)} />
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{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}</>
)}
<CopyButton value={reading.value.toString()} title="Wert kopieren" />
</span>
{canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<button
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel, reading })}
className="text-gray-400 hover:text-blue-600"
title="Bearbeiten"
>
<Edit className="w-3 h-3" />
</button>
<button
onClick={() => {
if (confirm('Zählerstand wirklich löschen?')) {
deleteReadingMutation.mutate({ meterId: meter.id, readingId: reading.id });
}
}}
className="text-gray-400 hover:text-red-600"
title="Löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<p className="text-gray-500">Keine Zähler vorhanden.</p>
)}
{showReadingModal && (
<MeterReadingModal
isOpen={true}
onClose={() => setShowReadingModal(null)}
meterId={showReadingModal.meterId}
meterType={showReadingModal.meterType}
tariffModel={showReadingModal.tariffModel as any}
customerId={customerId}
/>
)}
{editingReading && (
<MeterReadingModal
isOpen={true}
onClose={() => 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 && (
<Modal isOpen={true} onClose={() => setDeleteError(null)} title="Zähler kann nicht gelöscht werden">
<p className="text-sm text-gray-600 mb-4">
Der Zähler ist noch folgenden Verträgen zugeordnet und kann daher nicht gelöscht werden:
</p>
<div className="space-y-2">
{deleteError.match(/[A-Z]+-[A-Z0-9]+/g)?.map((contractNumber) => (
<Link
key={contractNumber}
to={`/contracts?search=${contractNumber}`}
onClick={() => 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"
>
<FileText className="w-4 h-4" />
<span className="font-mono">{contractNumber}</span>
</Link>
)) ?? (
<p className="text-sm text-red-600">{deleteError}</p>
)}
</div>
<p className="text-xs text-gray-500 mt-4">
Bitte entfernen Sie den Zähler zuerst aus den oben genannten Verträgen.
</p>
<div className="flex justify-end mt-4">
<Button variant="secondary" onClick={() => setDeleteError(null)}>
Schließen
</Button>
</div>
</Modal>
)}
</div>
);
}
function ContractsTab({
customerId,
}: {
customerId: number;
}) {
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
const [showStatusInfo, setShowStatusInfo] = useState(false);
// Lade Vertragsbaum statt flacher Liste
const { data: treeData, isLoading } = useQuery({
queryKey: ['contract-tree', customerId],
queryFn: () => contractApi.getTreeForCustomer(customerId),
});
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<string, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ-Versicherung',
};
const statusVariants: Record<string, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
};
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: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, 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 => (
<div key={node.contract.id}>
{renderContractNode(node, depth)}
</div>
));
};
// 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 (
<div key={contract.id}>
<div
className={`
border rounded-lg p-4 transition-colors
${isPredecessor ? 'ml-6 border-l-4 border-l-gray-300 bg-gray-50' : 'hover:bg-gray-50'}
`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{/* Aufklapp-Button nur bei Wurzelknoten mit Historie */}
{!isPredecessor && hasHistory ? (
<button
onClick={() => toggleExpand(contract.id)}
className="p-1 hover:bg-gray-200 rounded transition-colors"
title={isExpanded ? 'Einklappen' : 'Vorgänger anzeigen'}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
) : !isPredecessor ? (
<div className="w-6" /> // Platzhalter für Ausrichtung
) : null}
<Link to={`/contracts/${contract.id}`} state={pushHistory(location.pathname + location.search, (location as any).state)} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</Link>
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
<Badge variant={statusVariants[contract.status] || 'default'}>{contract.status}</Badge>
{depth === 0 && !isPredecessor && (
<button
onClick={(e) => { e.stopPropagation(); setShowStatusInfo(true); }}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Status-Erklärung"
>
<Info className="w-4 h-4" />
</button>
)}
{isPredecessor && (
<span className="text-xs text-gray-500 ml-2">(Vorgänger)</span>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'customer', customerId: customerId.toString() }
})}
title="Ansehen"
>
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}/edit`)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('contracts:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertrag wirklich löschen?')) {
deleteMutation.mutate(contract.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
{(contract.providerName || contract.provider?.name) && (
<p className={`flex items-center gap-1 ${isPredecessor ? 'ml-6' : ''}`}>
{contract.providerName || contract.provider?.name}
{(contract.tariffName || contract.tariff?.name) && ` - ${contract.tariffName || contract.tariff?.name}`}
<CopyButton value={(contract.providerName || contract.provider?.name || '') + ((contract.tariffName || contract.tariff?.name) ? ` - ${contract.tariffName || contract.tariff?.name}` : '')} />
</p>
)}
{(() => {
const typeInfo = getContractTypeInfo(contract as any);
return typeInfo ? (
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
</p>
) : null;
})()}
{contract.startDate && (
<p className={`text-sm text-gray-500 ${isPredecessor ? 'ml-6' : ''}`}>
Beginn: {formatDate(contract.startDate)}
{contract.endDate &&
` | Ende: ${formatDate(contract.endDate)}`}
</p>
)}
</div>
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
{((depth === 0 && isExpanded) || depth > 0) && predecessors.length > 0 && (
<div className="mt-2">
{renderPredecessors(predecessors, depth + 1)}
</div>
)}
</div>
);
};
if (isLoading) {
return (
<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>
);
}
return (
<div>
{hasPermission('contracts:create') && (
<div className="mb-4">
<Link to={`/contracts/new?customerId=${customerId}`}>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Vertrag anlegen
</Button>
</Link>
</div>
)}
{contractTree.length > 0 ? (
<div className="space-y-4">
{contractTree.map(node => renderContractNode(node, 0))}
</div>
) : (
<p className="text-gray-500">Keine Verträge vorhanden.</p>
)}
{/* Status Info Modal */}
{showStatusInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/20" onClick={() => setShowStatusInfo(false)} />
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
<button onClick={() => setShowStatusInfo(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
{statusDescriptions.map(({ status, label, description, color }) => (
<div key={status} className="flex items-start gap-2">
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
<span className="text-sm text-gray-600">{description}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
// Gespeichertes Passwort anzeigen
function StoredPasswordDisplay({ customerId }: { customerId: number }) {
const [showStoredPassword, setShowStoredPassword] = useState(false);
const [storedPassword, setStoredPassword] = useState<string | null>(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 (
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-green-600">Passwort ist gesetzt</p>
<button
type="button"
onClick={handleShowPassword}
className="text-xs text-blue-600 hover:underline flex items-center gap-1"
disabled={isLoading}
>
{isLoading ? (
'Laden...'
) : showStoredPassword ? (
<>
<EyeOff className="w-3 h-3" />
Verbergen
</>
) : (
<>
<Eye className="w-3 h-3" />
Anzeigen
</>
)}
</button>
{showStoredPassword && storedPassword && (
<span className="text-xs font-mono bg-gray-100 px-2 py-1 rounded flex items-center gap-1">
{storedPassword}
<CopyButton value={storedPassword} />
</span>
)}
{showStoredPassword && !storedPassword && (
<span className="text-xs text-gray-500">(Passwort nicht verfügbar)</span>
)}
</div>
);
}
// 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<CustomerSummary[]>([]);
const [isSearching, setIsSearching] = useState(false);
// 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] });
},
});
// Passwort setzen
const setPasswordMutation = useMutation({
mutationFn: (password: string) => customerApi.setPortalPassword(customerId, password),
onSuccess: () => {
setNewPassword('');
queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] });
alert('Passwort wurde gesetzt');
},
onError: (error: Error) => {
alert(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 <div className="text-center py-4 text-gray-500">Laden...</div>;
}
const portal = portalData?.data;
const representatives = representativesData?.data || [];
return (
<div className="space-y-6">
{/* Portal-Einstellungen */}
<div className="border rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Globe className="w-5 h-5 text-gray-400" />
<h3 className="font-medium">Portal-Zugang</h3>
</div>
<div className="space-y-4">
{/* Portal aktiviert */}
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={portal?.portalEnabled || false}
onChange={(e) => updatePortalMutation.mutate({ portalEnabled: e.target.checked })}
className="rounded w-5 h-5"
disabled={!canEdit}
/>
<span>Portal aktiviert</span>
{portal?.portalEnabled && (
<Badge variant="success">Aktiv</Badge>
)}
</label>
{/* Portal E-Mail */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Portal E-Mail</label>
<div className="flex gap-2">
<Input
value={portal?.portalEmail || ''}
onChange={(e) => updatePortalMutation.mutate({ portalEmail: e.target.value || null })}
placeholder="portal@example.com"
disabled={!canEdit || !portal?.portalEnabled}
className="flex-1"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Diese E-Mail wird für den Login ins Kundenportal verwendet.
</p>
</div>
{/* Passwort setzen */}
{portal?.portalEnabled && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{portal?.hasPassword ? 'Neues Passwort setzen' : 'Passwort setzen'}
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
disabled={!canEdit}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button
onClick={() => setPasswordMutation.mutate(newPassword)}
disabled={!canEdit || newPassword.length < 6 || setPasswordMutation.isPending}
>
{setPasswordMutation.isPending ? 'Speichern...' : 'Setzen'}
</Button>
</div>
{portal?.hasPassword && (
<StoredPasswordDisplay customerId={customerId} />
)}
</div>
)}
{/* Letzte Anmeldung */}
{portal?.portalLastLogin && (
<p className="text-sm text-gray-500">
Letzte Anmeldung: {new Date(portal.portalLastLogin).toLocaleString('de-DE')}
</p>
)}
</div>
</div>
{/* Vertreter-Verwaltung */}
<div className="border rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<UserPlus className="w-5 h-5 text-gray-400" />
<h3 className="font-medium">Vertreter (können Verträge einsehen)</h3>
</div>
<p className="text-sm text-gray-500 mb-4">
Hier können Sie anderen Kunden erlauben, die Verträge dieses Kunden einzusehen.
Beispiel: Der Sohn kann die Verträge seiner Mutter einsehen.
</p>
{/* Vertreter-Suche */}
{canEdit && (
<div className="mb-4">
<div className="flex gap-2">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Kunden suchen (Name, Kundennummer)..."
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="flex-1"
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={searchTerm.length < 2 || isSearching}
>
<Search className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-gray-500 mt-1">
Nur Kunden mit aktiviertem Portal können als Vertreter hinzugefügt werden.
</p>
{/* Suchergebnisse */}
{searchResults.length > 0 && (
<div className="mt-2 border rounded-lg divide-y">
{searchResults.map((customer) => (
<div key={customer.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div>
<p className="font-medium">
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
</p>
<p className="text-sm text-gray-500">{customer.customerNumber}</p>
</div>
<Button
size="sm"
onClick={() => addRepMutation.mutate(customer.id)}
disabled={addRepMutation.isPending}
>
<Plus className="w-4 h-4 mr-1" />
Hinzufügen
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Aktuelle Vertreter */}
{representatives.length > 0 ? (
<div className="space-y-2">
{representatives.map((rep: CustomerRepresentative) => (
<div key={rep.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium">
{rep.representative?.companyName ||
`${rep.representative?.firstName} ${rep.representative?.lastName}`}
</p>
<p className="text-sm text-gray-500">{rep.representative?.customerNumber}</p>
</div>
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertreter wirklich entfernen?')) {
removeRepMutation.mutate(rep.representativeId);
}
}}
>
<X className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">Keine Vertreter konfiguriert.</p>
)}
</div>
</div>
);
}
// 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,
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;
// Update form when address prop changes
if (isEditing && formData.street !== address.street) {
setFormData(getInitialFormData());
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Select
label="Adresstyp"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
options={[
{ value: 'DELIVERY_RESIDENCE', label: 'Liefer-/Meldeadresse' },
{ value: 'BILLING', label: 'Rechnungsadresse' },
]}
/>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Input
label="Straße"
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
required
/>
</div>
<Input
label="Hausnr."
value={formData.houseNumber}
onChange={(e) => setFormData({ ...formData, houseNumber: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<Input
label="PLZ"
value={formData.postalCode}
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
required
/>
<div className="col-span-2">
<Input
label="Ort"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
required
/>
</div>
</div>
<Input
label="Land"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isDefault}
onChange={(e) => setFormData({ ...formData, isDefault: e.target.checked })}
className="rounded"
/>
Als Standard setzen
</label>
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
{formData.type === 'DELIVERY_RESIDENCE' && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-1">Eigentümer</h4>
<p className="text-xs text-gray-500 mb-3">
Nur ausfüllen wenn der Kunde nicht selbst Eigentümer ist (z.B. Mietwohnung).
</p>
<div className="space-y-3">
<Input
label="Firma (optional)"
value={formData.ownerCompany}
onChange={(e) => setFormData({ ...formData, ownerCompany: e.target.value })}
placeholder="z.B. Wohnungsbaugesellschaft"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Vorname"
value={formData.ownerFirstName}
onChange={(e) => setFormData({ ...formData, ownerFirstName: e.target.value })}
/>
<Input
label="Nachname"
value={formData.ownerLastName}
onChange={(e) => setFormData({ ...formData, ownerLastName: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Input
label="Straße"
value={formData.ownerStreet}
onChange={(e) => setFormData({ ...formData, ownerStreet: e.target.value })}
/>
</div>
<Input
label="Hausnr."
value={formData.ownerHouseNumber}
onChange={(e) => setFormData({ ...formData, ownerHouseNumber: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<Input
label="PLZ"
value={formData.ownerPostalCode}
onChange={(e) => setFormData({ ...formData, ownerPostalCode: e.target.value })}
/>
<div className="col-span-2">
<Input
label="Ort"
value={formData.ownerCity}
onChange={(e) => setFormData({ ...formData, ownerCity: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<Input
label="Telefon"
value={formData.ownerPhone}
onChange={(e) => setFormData({ ...formData, ownerPhone: e.target.value })}
/>
<Input
label="Mobil"
value={formData.ownerMobile}
onChange={(e) => setFormData({ ...formData, ownerMobile: e.target.value })}
/>
<Input
label="E-Mail"
value={formData.ownerEmail}
onChange={(e) => setFormData({ ...formData, ownerEmail: e.target.value })}
type="email"
/>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
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 || '',
bic: bankCard?.bic || '',
bankName: bankCard?.bankName || '',
expiryDate: bankCard?.expiryDate ? new Date(bankCard.expiryDate).toISOString().split('T')[0] : '',
isActive: bankCard?.isActive ?? true,
});
const [formData, setFormData] = useState(getInitialFormData);
// Reset form when bankCard changes
useState(() => {
setFormData(getInitialFormData());
});
const createMutation = useMutation({
mutationFn: (data: any) => bankCardApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({ accountHolder: '', iban: '', bic: '', bankName: '', expiryDate: '', 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;
// Update form when bankCard prop changes
if (isEditing && formData.iban !== bankCard.iban) {
setFormData(getInitialFormData());
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Kontoinhaber"
value={formData.accountHolder}
onChange={(e) => setFormData({ ...formData, accountHolder: e.target.value })}
required
/>
<Input
label="IBAN"
value={formData.iban}
onChange={(e) => setFormData({ ...formData, iban: e.target.value })}
required
/>
<Input
label="BIC"
value={formData.bic}
onChange={(e) => setFormData({ ...formData, bic: e.target.value })}
/>
<Input
label="Bank"
value={formData.bankName}
onChange={(e) => setFormData({ ...formData, bankName: e.target.value })}
/>
<Input
label="Ablaufdatum"
type="date"
value={formData.expiryDate}
onChange={(e) => setFormData({ ...formData, expiryDate: e.target.value })}
onClear={() => setFormData({ ...formData, expiryDate: '' })}
/>
{isEditing && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
{!isEditing && (
<p className="text-sm text-gray-500 bg-gray-50 p-3 rounded">
Dokument-Upload ist nach dem Speichern in der Übersicht möglich.
</p>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
function DocumentModal({
isOpen,
onClose,
customerId,
document,
}: {
isOpen: boolean;
onClose: () => void;
customerId: number;
document?: IdentityDocument | null;
}) {
const queryClient = useQueryClient();
const isEditing = !!document;
const getInitialFormData = () => ({
type: document?.type || 'ID_CARD' as const,
documentNumber: document?.documentNumber || '',
issuingAuthority: document?.issuingAuthority || '',
issueDate: document?.issueDate ? new Date(document.issueDate).toISOString().split('T')[0] : '',
expiryDate: document?.expiryDate ? new Date(document.expiryDate).toISOString().split('T')[0] : '',
isActive: document?.isActive ?? true,
licenseClasses: document?.licenseClasses || '',
licenseIssueDate: document?.licenseIssueDate ? new Date(document.licenseIssueDate).toISOString().split('T')[0] : '',
});
const [formData, setFormData] = useState(getInitialFormData);
const createMutation = useMutation({
mutationFn: (data: any) => documentApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({
type: 'ID_CARD',
documentNumber: '',
issuingAuthority: '',
issueDate: '',
expiryDate: '',
isActive: true,
licenseClasses: '',
licenseIssueDate: '',
});
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => documentApi.update(document!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: any = {
...formData,
issueDate: formData.issueDate ? new Date(formData.issueDate) : undefined,
expiryDate: formData.expiryDate ? new Date(formData.expiryDate) : undefined,
};
// Führerschein-spezifische Felder nur bei Führerschein senden
if (formData.type === 'DRIVERS_LICENSE') {
data.licenseClasses = formData.licenseClasses || undefined;
data.licenseIssueDate = formData.licenseIssueDate ? new Date(formData.licenseIssueDate) : undefined;
} else {
delete data.licenseClasses;
delete data.licenseIssueDate;
}
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when document prop changes
if (isEditing && formData.documentNumber !== document.documentNumber) {
setFormData(getInitialFormData());
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Select
label="Ausweistyp"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
options={[
{ value: 'ID_CARD', label: 'Personalausweis' },
{ value: 'PASSPORT', label: 'Reisepass' },
{ value: 'DRIVERS_LICENSE', label: 'Führerschein' },
{ value: 'OTHER', label: 'Sonstiges' },
]}
/>
<Input
label="Ausweisnummer"
value={formData.documentNumber}
onChange={(e) => setFormData({ ...formData, documentNumber: e.target.value })}
required
/>
<Input
label="Ausstellende Behörde"
value={formData.issuingAuthority}
onChange={(e) => setFormData({ ...formData, issuingAuthority: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Ausstellungsdatum"
type="date"
value={formData.issueDate}
onChange={(e) => setFormData({ ...formData, issueDate: e.target.value })}
onClear={() => setFormData({ ...formData, issueDate: '' })}
/>
<Input
label="Ablaufdatum"
type="date"
value={formData.expiryDate}
onChange={(e) => setFormData({ ...formData, expiryDate: e.target.value })}
onClear={() => setFormData({ ...formData, expiryDate: '' })}
/>
</div>
{formData.type === 'DRIVERS_LICENSE' && (
<>
<Input
label="Führerscheinklassen"
value={formData.licenseClasses}
onChange={(e) => setFormData({ ...formData, licenseClasses: e.target.value })}
placeholder="z.B. B, BE, AM, L"
/>
<Input
label="Erwerb Klasse B (Pkw)"
type="date"
value={formData.licenseIssueDate}
onChange={(e) => setFormData({ ...formData, licenseIssueDate: e.target.value })}
onClear={() => setFormData({ ...formData, licenseIssueDate: '' })}
/>
</>
)}
{isEditing && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
{!isEditing && (
<p className="text-sm text-gray-500 bg-gray-50 p-3 rounded">
Dokument-Upload ist nach dem Speichern in der Übersicht möglich.
</p>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
function MeterModal({
isOpen,
onClose,
customerId,
meter,
}: {
isOpen: boolean;
onClose: () => void;
customerId: number;
meter?: Meter | null;
}) {
const queryClient = useQueryClient();
const isEditing = !!meter;
const getInitialFormData = () => ({
meterNumber: meter?.meterNumber || '',
type: meter?.type || 'ELECTRICITY' as const,
tariffModel: meter?.tariffModel || 'SINGLE' as const,
location: meter?.location || '',
isActive: meter?.isActive ?? true,
});
const [formData, setFormData] = useState(getInitialFormData);
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.update(meter!.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;
// Update form when meter prop changes
if (isEditing && formData.meterNumber !== meter.meterNumber) {
setFormData(getInitialFormData());
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zähler bearbeiten' : 'Zähler hinzufügen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Zählernummer"
value={formData.meterNumber}
onChange={(e) => setFormData({ ...formData, meterNumber: e.target.value })}
required
/>
<Select
label="Zählertyp"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
options={[
{ value: 'ELECTRICITY', label: 'Strom' },
{ value: 'GAS', label: 'Gas' },
]}
/>
{formData.type === 'ELECTRICITY' && (
<Select
label="Tarifmodell"
value={formData.tariffModel}
onChange={(e) => setFormData({ ...formData, tariffModel: e.target.value as any })}
options={[
{ value: 'SINGLE', label: 'Eintarifzähler (Standard)' },
{ value: 'DUAL', label: 'Zweitarifzähler (HT/NT)' },
]}
/>
)}
<Input
label="Standort"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
placeholder="z.B. Keller, Wohnung"
/>
{isEditing && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
function MeterReadingModal({
isOpen,
onClose,
meterId,
meterType,
tariffModel,
customerId,
reading,
}: {
isOpen: boolean;
onClose: () => void;
meterId: number;
meterType: 'ELECTRICITY' | 'GAS';
tariffModel?: 'SINGLE' | 'DUAL';
customerId: number;
reading?: { id: number; readingDate: string; value: number; valueNt?: number; unit: string; notes?: string } | null;
}) {
const queryClient = useQueryClient();
const isEditing = !!reading;
const defaultUnit = meterType === 'ELECTRICITY' ? 'kWh' : 'm³';
const isDualTariff = tariffModel === 'DUAL';
const getInitialFormData = () => ({
readingDate: reading?.readingDate
? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '',
valueNt: reading?.valueNt?.toString() || '',
notes: reading?.notes || '',
});
const [formData, setFormData] = useState(getInitialFormData);
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: Record<string, unknown> = {
readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value),
unit: defaultUnit,
notes: formData.notes || undefined,
};
if (isDualTariff && formData.valueNt) {
data.valueNt = parseFloat(formData.valueNt);
}
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when reading prop changes
if (isEditing && formData.value !== reading.value.toString()) {
setFormData(getInitialFormData());
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Ablesedatum"
type="date"
value={formData.readingDate}
onChange={(e) => setFormData({ ...formData, readingDate: e.target.value })}
required
/>
<div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
<div className={isDualTariff ? '' : 'col-span-2'}>
<Input
label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
type="number"
step="0.01"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
required
/>
</div>
{isDualTariff && (
<div>
<Input
label="NT-Stand (Niedertarif)"
type="number"
step="0.01"
value={formData.valueNt}
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
required
/>
</div>
)}
{!isDualTariff && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
</div>
</div>
)}
</div>
<Input
label="Notizen"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Optionale Notizen..."
/>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
// ==================== STRESSFREI-WECHSELN E-MAIL TAB ====================
const STRESSFREI_DOMAIN = '@stressfrei-wechseln.de';
function StressfreiEmailsTab({
customerId,
emails,
canEdit,
showInactive,
onToggleInactive,
onAdd,
onEdit,
}: {
customerId: number;
emails: StressfreiEmail[];
canEdit: boolean;
showInactive: boolean;
onToggleInactive: () => void;
onAdd: () => void;
onEdit: (email: StressfreiEmail) => void;
}) {
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
stressfreiEmailApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: stressfreiEmailApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const filtered = showInactive ? emails : emails.filter((e) => e.isActive);
return (
<div>
<div className="flex items-center gap-4 mb-4">
{canEdit && (
<Button size="sm" onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
Adresse hinzufügen
</Button>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={onToggleInactive}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
<p className="text-sm text-gray-500 mb-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
<strong>Hinweis:</strong> Hier werden E-Mail-Weiterleitungsadressen verwaltet, die für die Registrierung bei Anbietern verwendet werden.
E-Mails an diese Adressen werden sowohl an den Kunden als auch an Sie weitergeleitet.
</p>
{filtered.length > 0 ? (
<div className="space-y-3">
{filtered.map((emailItem) => (
<div
key={emailItem.id}
className={`border rounded-lg p-4 ${!emailItem.isActive ? 'opacity-50 bg-gray-50' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400" />
<span className="font-mono text-sm">{emailItem.email}</span>
<CopyButton value={emailItem.email} />
{!emailItem.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{emailItem.notes && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-500">
<FileText className="w-4 h-4 flex-shrink-0" />
<span>{emailItem.notes}</span>
</div>
)}
</div>
{canEdit && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(emailItem)}
title="Bearbeiten"
>
<Edit className="w-4 h-4" />
</Button>
{emailItem.isActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Adresse deaktivieren?')) {
updateMutation.mutate({ id: emailItem.id, data: { isActive: false } });
}
}}
title="Deaktivieren"
>
<EyeOff className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Adresse wieder aktivieren?')) {
updateMutation.mutate({ id: emailItem.id, data: { isActive: true } });
}
}}
title="Aktivieren"
>
<Eye className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Adresse wirklich löschen?')) {
deleteMutation.mutate(emailItem.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">Keine Stressfrei-Wechseln Adressen vorhanden.</p>
)}
</div>
);
}
// ==================== CREDENTIALS DISPLAY ====================
function CredentialsDisplay({
credentials,
onHide,
onResetPassword,
isResettingPassword,
}: {
credentials: {
email: string;
password: string;
imap: { server: string; port: number; encryption: string } | null;
smtp: { server: string; port: number; encryption: string } | null;
};
onHide: () => void;
onResetPassword: () => void;
isResettingPassword: boolean;
}) {
const [copiedField, setCopiedField] = useState<string | null>(null);
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
} catch {
// Fallback für ältere Browser
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
}
};
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
<button
type="button"
onClick={() => copyToClipboard(text, fieldName)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="In Zwischenablage kopieren"
>
{copiedField === fieldName ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
);
const imapString = credentials.imap
? `${credentials.imap.server}:${credentials.imap.port}`
: '';
const smtpString = credentials.smtp
? `${credentials.smtp.server}:${credentials.smtp.port}`
: '';
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Zugangsdaten
</span>
<button
type="button"
onClick={onHide}
className="text-gray-400 hover:text-gray-600 p-1 hover:bg-gray-200 rounded"
title="Zugangsdaten ausblenden"
>
<EyeOff className="w-4 h-4" />
</button>
</div>
{/* Benutzername & Passwort */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-lg p-3 border border-gray-100">
<label className="text-xs text-gray-500 block mb-1">Benutzername</label>
<div className="flex items-center gap-2">
<code className="text-sm text-gray-900 font-mono flex-1 break-all">
{credentials.email}
</code>
<CopyButton text={credentials.email} fieldName="email" />
</div>
</div>
<div className="bg-white rounded-lg p-3 border border-gray-100">
<label className="text-xs text-gray-500 block mb-1">Passwort</label>
<div className="flex items-center gap-2">
<code className="text-sm text-gray-900 font-mono flex-1 break-all">
{credentials.password}
</code>
<CopyButton text={credentials.password} fieldName="password" />
</div>
<button
type="button"
onClick={onResetPassword}
disabled={isResettingPassword}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
{isResettingPassword ? 'Generiere...' : 'Neu generieren'}
</button>
</div>
</div>
{/* Server-Einstellungen */}
<div className="grid grid-cols-2 gap-3">
{credentials.imap && (
<div className="bg-white rounded-lg p-3 border border-gray-100">
<label className="text-xs text-gray-500 block mb-1">
IMAP (Empfang)
</label>
<div className="flex items-center gap-2">
<code className="text-sm text-gray-900 font-mono flex-1">
{imapString}
</code>
<CopyButton text={imapString} fieldName="imap" />
</div>
<span className="text-xs text-gray-400 mt-1 block">
{credentials.imap.encryption}
</span>
</div>
)}
{credentials.smtp && (
<div className="bg-white rounded-lg p-3 border border-gray-100">
<label className="text-xs text-gray-500 block mb-1">
SMTP (Versand)
</label>
<div className="flex items-center gap-2">
<code className="text-sm text-gray-900 font-mono flex-1">
{smtpString}
</code>
<CopyButton text={smtpString} fieldName="smtp" />
</div>
<span className="text-xs text-gray-400 mt-1 block">
{credentials.smtp.encryption}
</span>
</div>
)}
</div>
</div>
);
}
// ==================== STRESSFREI-EMAIL MODAL ====================
function StressfreiEmailModal({
isOpen,
onClose,
customerId,
email,
customerEmail,
}: {
isOpen: boolean;
onClose: () => void;
customerId: number;
email?: StressfreiEmail | null;
customerEmail?: string;
}) {
const [localPart, setLocalPart] = useState('');
const [notes, setNotes] = useState('');
const [provisionAtProvider, setProvisionAtProvider] = useState(false);
const [createMailbox, setCreateMailbox] = useState(false);
const [provisionError, setProvisionError] = useState<string | null>(null);
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
const [isProvisioning, setIsProvisioning] = useState(false);
const [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
const [mailboxEnabled, setMailboxEnabled] = useState(false);
const [showCredentials, setShowCredentials] = useState(false);
const [credentials, setCredentials] = useState<{
email: string;
password: string;
imap: { server: string; port: number; encryption: string } | null;
smtp: { server: string; port: number; encryption: string } | null;
} | null>(null);
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const queryClient = useQueryClient();
const isEditing = !!email;
// Prüfe ob ein Provider konfiguriert ist
const { data: configsData } = useQuery({
queryKey: ['email-provider-configs'],
queryFn: () => emailProviderApi.getConfigs(),
enabled: isOpen, // Immer laden wenn Modal offen
});
const hasProvider = (configsData?.data || []).some(c => c.isActive && c.isDefault);
// Helper: Extrahiert den lokalen Teil einer E-Mail-Adresse (vor dem @)
const extractLocalPart = (fullEmail: string) => {
if (!fullEmail) return '';
const atIndex = fullEmail.indexOf('@');
return atIndex > 0 ? fullEmail.substring(0, atIndex) : fullEmail;
};
// Prüft ob E-Mail beim Provider existiert
const checkProviderStatus = async (emailLocalPart: string) => {
if (!hasProvider || !emailLocalPart) return;
setProviderStatus('checking');
try {
const result = await emailProviderApi.checkEmailExists(emailLocalPart);
setProviderStatus(result.data?.exists ? 'exists' : 'not_exists');
} catch {
setProviderStatus('error');
}
};
// E-Mail nachträglich beim Provider anlegen
const handleProvisionNow = async () => {
if (!customerEmail || !localPart) return;
setIsProvisioning(true);
setProvisionError(null);
try {
const result = await emailProviderApi.provisionEmail(localPart, customerEmail);
if (result.data?.success) {
setProviderStatus('exists');
} else {
setProvisionError(result.data?.error || 'Provisionierung fehlgeschlagen');
}
} catch (error) {
setProvisionError(error instanceof Error ? error.message : 'Fehler bei der Provisionierung');
} finally {
setIsProvisioning(false);
}
};
// Mailbox nachträglich aktivieren
const handleEnableMailbox = async () => {
if (!email) return;
setIsEnablingMailbox(true);
setProvisionError(null);
try {
const result = await stressfreiEmailApi.enableMailbox(email.id);
if (result.success) {
setMailboxEnabled(true);
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
} else {
setProvisionError(result.error || 'Mailbox-Aktivierung fehlgeschlagen');
}
} catch (error) {
setProvisionError(error instanceof Error ? error.message : 'Fehler bei der Mailbox-Aktivierung');
} finally {
setIsEnablingMailbox(false);
}
};
// Mailbox-Status mit Provider synchronisieren
const syncMailboxStatusFromProvider = async () => {
if (!email) return;
try {
const result = await stressfreiEmailApi.syncMailboxStatus(email.id);
if (result.success && result.data) {
setMailboxEnabled(result.data.hasMailbox);
if (result.data.wasUpdated) {
// DB wurde aktualisiert, Query invalidieren
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
}
}
} catch (error) {
console.error('Fehler beim Synchronisieren des Mailbox-Status:', error);
}
};
// Mailbox-Zugangsdaten laden
const loadCredentials = async () => {
if (!email) return;
setIsLoadingCredentials(true);
try {
const result = await stressfreiEmailApi.getMailboxCredentials(email.id);
if (result.success && result.data) {
setCredentials(result.data);
setShowCredentials(true);
}
} catch (error) {
console.error('Fehler beim Laden der Zugangsdaten:', error);
} finally {
setIsLoadingCredentials(false);
}
};
// Passwort zurücksetzen
const handleResetPassword = async () => {
if (!email) return;
if (!confirm('Neues Passwort generieren? Das alte Passwort wird ungültig.')) return;
setIsResettingPassword(true);
try {
const result = await stressfreiEmailApi.resetPassword(email.id);
if (result.success && result.data) {
// Credentials mit neuem Passwort aktualisieren
if (credentials) {
setCredentials({ ...credentials, password: result.data.password });
}
alert('Passwort wurde erfolgreich zurückgesetzt.');
} else {
alert(result.error || 'Fehler beim Zurücksetzen des Passworts');
}
} catch (error) {
console.error('Fehler beim Zurücksetzen des Passworts:', error);
alert(error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts');
} finally {
setIsResettingPassword(false);
}
};
// Reset form when modal opens or email changes
useEffect(() => {
if (isOpen) {
if (email) {
const emailLocalPart = extractLocalPart(email.email);
setLocalPart(emailLocalPart);
setNotes(email.notes || '');
setProviderStatus('idle');
setMailboxEnabled(email.hasMailbox || false);
// Status beim Provider prüfen wenn Provider vorhanden
if (hasProvider) {
checkProviderStatus(emailLocalPart);
// Mailbox-Status synchronisieren
syncMailboxStatusFromProvider();
}
} else {
setLocalPart('');
setNotes('');
setProvisionAtProvider(false);
setCreateMailbox(false);
setProviderStatus('idle');
setMailboxEnabled(false);
}
setProvisionError(null);
// Zugangsdaten zurücksetzen
setShowCredentials(false);
setCredentials(null);
}
}, [isOpen, email, hasProvider]);
const createMutation = useMutation({
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
return stressfreiEmailApi.create(customerId, {
email: data.email,
notes: data.notes,
provisionAtProvider: data.provision,
createMailbox: data.createMailbox,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
setLocalPart('');
setNotes('');
setProvisionAtProvider(false);
setCreateMailbox(false);
onClose();
},
onError: (error) => {
setProvisionError(error instanceof Error ? error.message : 'Fehler bei der Provisionierung');
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<StressfreiEmail>) =>
stressfreiEmailApi.update(email!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setProvisionError(null);
const fullEmail = localPart + STRESSFREI_DOMAIN;
if (isEditing) {
updateMutation.mutate({
email: fullEmail,
notes: notes || undefined,
});
} else {
createMutation.mutate({
email: fullEmail,
notes: notes || undefined,
provision: provisionAtProvider,
createMailbox: provisionAtProvider && createMailbox,
});
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
E-Mail-Adresse
</label>
<div className="flex">
<input
type="text"
value={localPart}
onChange={(e) => setLocalPart(e.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ''))}
placeholder="kunde-freenet"
required
className="block w-full px-3 py-2 border border-gray-300 rounded-l-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-100 text-gray-600 rounded-r-lg text-sm">
{STRESSFREI_DOMAIN}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
Vollständige Adresse: <span className="font-mono">{localPart || '...'}{STRESSFREI_DOMAIN}</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. für Freenet-Konten, für Klarmobil..."
/>
</div>
{/* Provisionierung - beim Erstellen: Checkbox, beim Bearbeiten: Status + Button */}
{hasProvider && customerEmail && (
<div className="bg-blue-50 p-3 rounded-lg">
{!isEditing ? (
// Erstellen-Modus: Checkboxen
<div className="space-y-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={provisionAtProvider}
onChange={(e) => {
setProvisionAtProvider(e.target.checked);
if (!e.target.checked) setCreateMailbox(false);
}}
className="mt-1 rounded border-gray-300"
/>
<div>
<span className="text-sm font-medium text-gray-700">
Beim E-Mail-Provider anlegen
</span>
<p className="text-xs text-gray-500 mt-1">
Die E-Mail-Weiterleitung wird automatisch auf dem konfigurierten Server erstellt.
Weiterleitungsziel: {customerEmail}
</p>
</div>
</label>
{provisionAtProvider && (
<label className="flex items-start gap-2 cursor-pointer ml-6">
<input
type="checkbox"
checked={createMailbox}
onChange={(e) => setCreateMailbox(e.target.checked)}
className="mt-1 rounded border-gray-300"
/>
<div>
<span className="text-sm font-medium text-gray-700">
Echte Mailbox erstellen (IMAP/SMTP-Zugang)
</span>
<p className="text-xs text-gray-500 mt-1">
Ermöglicht E-Mails direkt im CRM zu empfangen und zu versenden.
</p>
</div>
</label>
)}
</div>
) : (
// Bearbeiten-Modus: Status anzeigen
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
E-Mail-Provider Status
</span>
{providerStatus === 'checking' && (
<span className="text-xs text-gray-500">Prüfe...</span>
)}
{providerStatus === 'exists' && (
<span className="text-xs text-green-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Beim Provider vorhanden
</span>
)}
{providerStatus === 'not_exists' && (
<span className="text-xs text-orange-600">Nicht beim Provider angelegt</span>
)}
{providerStatus === 'error' && (
<span className="text-xs text-red-600">Status konnte nicht geprüft werden</span>
)}
</div>
{/* Jetzt anlegen Button wenn nicht vorhanden */}
{providerStatus === 'not_exists' && (
<div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-500 mb-2">
Die E-Mail-Weiterleitung ist noch nicht auf dem Server eingerichtet.
Weiterleitungsziel: {customerEmail}
</p>
<Button
type="button"
size="sm"
onClick={handleProvisionNow}
disabled={isProvisioning}
>
{isProvisioning ? 'Wird angelegt...' : 'Jetzt beim Provider anlegen'}
</Button>
</div>
)}
{/* Erneut prüfen bei Fehler */}
{providerStatus === 'error' && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => checkProviderStatus(localPart)}
>
Erneut prüfen
</Button>
)}
{/* Mailbox-Status anzeigen wenn Provider vorhanden */}
{providerStatus === 'exists' && (
<div className="pt-3 mt-3 border-t border-blue-100">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
Mailbox (IMAP/SMTP)
</span>
{mailboxEnabled ? (
<span className="text-xs text-green-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Mailbox aktiv
</span>
) : (
<span className="text-xs text-orange-600">Keine Mailbox</span>
)}
</div>
{!mailboxEnabled && (
<div className="mt-2">
<p className="text-xs text-gray-500 mb-2">
Aktiviere eine echte Mailbox um E-Mails direkt im CRM zu empfangen und zu versenden.
</p>
<Button
type="button"
size="sm"
onClick={handleEnableMailbox}
disabled={isEnablingMailbox}
>
{isEnablingMailbox ? 'Wird aktiviert...' : 'Mailbox aktivieren'}
</Button>
</div>
)}
{/* Zugangsdaten anzeigen wenn Mailbox aktiv */}
{mailboxEnabled && (
<div className="mt-3">
{!showCredentials ? (
<Button
type="button"
size="sm"
variant="secondary"
onClick={loadCredentials}
disabled={isLoadingCredentials}
>
{isLoadingCredentials ? (
'Laden...'
) : (
<>
<Eye className="w-4 h-4 mr-1" />
Zugangsdaten anzeigen
</>
)}
</Button>
) : credentials && (
<CredentialsDisplay
credentials={credentials}
onHide={() => setShowCredentials(false)}
onResetPassword={handleResetPassword}
isResettingPassword={isResettingPassword}
/>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Fehleranzeige für Provisionierung */}
{provisionError && (
<div className="bg-red-50 p-3 rounded-lg text-red-700 text-sm">
{provisionError}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending || !localPart}>
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
// Sperrhinweis wenn Datenschutz-Einwilligung fehlt
function ConsentBlockedContent({
onGoToConsentsTab,
}: {
onGoToConsentsTab?: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<div className="bg-amber-50 border border-amber-200 rounded-full p-4 mb-4">
<Lock className="w-8 h-8 text-amber-500" />
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
Datenschutz-Einwilligung erforderlich
</h3>
<p className="text-sm text-gray-600 mb-6 max-w-md">
Dieser Bereich ist erst verfügbar, wenn der Kunde der Datenschutzerklärung zugestimmt hat.
Die Einwilligung kann in Papierform oder online über den Einwilligungslink eingeholt werden.
</p>
<div className="bg-gray-50 border rounded-lg p-4 w-full max-w-md text-left space-y-3">
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-gray-700">Papierform</p>
<p className="text-xs text-gray-500">
Datenschutzerklärung ausdrucken, unterschreiben lassen und als PDF im Tab "Einwilligungen / Datenschutz" hochladen.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Mail className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-gray-700">Online per Link</p>
<p className="text-xs text-gray-500">
Einwilligungslink im Tab "Einwilligungen / Datenschutz" per E-Mail, WhatsApp, Telegram oder Signal an den Kunden senden.
</p>
</div>
</div>
</div>
{onGoToConsentsTab && (
<button
onClick={onGoToConsentsTab}
className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Shield className="w-4 h-4" />
Zum Tab "Einwilligungen / Datenschutz"
</button>
)}
</div>
);
}
// Consent Tab Component (DSGVO Einwilligungen)
const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
DATA_PROCESSING: {
label: 'Datenverarbeitung',
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
},
MARKETING_EMAIL: {
label: 'Elektronisches Marketing',
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
},
MARKETING_PHONE: {
label: 'Telefonmarketing',
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
},
DATA_SHARING_PARTNER: {
label: 'Datenweitergabe',
description: 'Weitergabe von Daten an Partnerunternehmen',
},
};
function ConsentTab({
customerId,
canEdit,
customerEmail,
customer,
onUpdate,
}: {
customerId: number;
canEdit: boolean;
customerEmail?: string;
customer?: Customer;
onUpdate?: () => void;
}) {
const queryClient = useQueryClient();
const { user } = useAuth();
const [showSendDropdown, setShowSendDropdown] = useState(false);
const { data: consentsData, isLoading } = useQuery({
queryKey: ['customer-consents', customerId],
queryFn: () => gdprApi.getCustomerConsents(customerId),
});
const sendLinkMutation = useMutation({
mutationFn: (channel: string) => gdprApi.sendConsentLink(customerId, channel),
onSuccess: (result, channel) => {
const url = result.data?.url;
if (channel === 'email') {
alert('Datenschutz-Link wurde per E-Mail gesendet.');
} else if (channel === 'whatsapp' && url) {
const text = encodeURIComponent(`Bitte stimmen Sie unserer Datenschutzerklärung zu: ${url}`);
window.open(`https://wa.me/?text=${text}`, '_blank');
} else if (channel === 'telegram' && url) {
const text = encodeURIComponent(`Bitte stimmen Sie unserer Datenschutzerklärung zu: ${url}`);
window.open(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${text}`, '_blank');
} else if (channel === 'signal' && url) {
const text = encodeURIComponent(`Bitte stimmen Sie unserer Datenschutzerklärung zu: ${url}`);
window.open(`signal://send?text=${text}`, '_blank');
}
setShowSendDropdown(false);
},
onError: (error) => {
alert(`Fehler beim Senden: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
setShowSendDropdown(false);
},
});
const updateMutation = useMutation({
mutationFn: ({ consentType, status, source }: { consentType: ConsentType; status: ConsentStatus; source: string }) =>
gdprApi.updateConsent(customerId, consentType, { status, source }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
},
});
const handleToggle = (consent: CustomerConsent) => {
const newStatus: ConsentStatus = consent.status === 'GRANTED' ? 'WITHDRAWN' : 'GRANTED';
updateMutation.mutate({
consentType: consent.consentType,
status: newStatus,
source: 'crm-backend',
});
};
const getStatusIcon = (status: ConsentStatus) => {
switch (status) {
case 'GRANTED':
return <ShieldCheck className="w-5 h-5 text-green-500" />;
case 'WITHDRAWN':
return <ShieldX className="w-5 h-5 text-red-500" />;
case 'PENDING':
return <ShieldAlert className="w-5 h-5 text-yellow-500" />;
default:
return <Shield className="w-5 h-5 text-gray-400" />;
}
};
const getStatusBadge = (status: ConsentStatus) => {
switch (status) {
case 'GRANTED':
return <Badge variant="success">Erteilt</Badge>;
case 'WITHDRAWN':
return <Badge variant="danger">Widerrufen</Badge>;
case 'PENDING':
return <Badge variant="warning">Ausstehend</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
const formatDate = (date?: string) => {
if (!date) return '-';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
return <div className="text-center py-4 text-gray-500">Laden...</div>;
}
const consents = consentsData?.data || [];
// Messaging-Kanäle basierend auf den Feldern des aktuellen Users
const userAny = user as any;
const channels: { key: string; label: string; icon: string; available: boolean }[] = [
{ key: 'email', label: 'Per E-Mail', icon: '✉️', available: !!customerEmail },
{ key: 'whatsapp', label: 'Per WhatsApp', icon: '💬', available: !!userAny?.whatsappNumber },
{ key: 'telegram', label: 'Per Telegram', icon: '📨', available: !!userAny?.telegramUsername },
{ key: 'signal', label: 'Per Signal', icon: '📱', available: !!userAny?.signalNumber },
];
const handlePrivacyPolicyUpload = async (file: File) => {
try {
await uploadApi.uploadPrivacyPolicy(customerId, file);
onUpdate?.();
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
}
};
const handlePrivacyPolicyDelete = async () => {
if (!confirm('Datenschutzerklärung wirklich löschen?')) return;
try {
await uploadApi.deletePrivacyPolicy(customerId);
onUpdate?.();
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
}
};
return (
<div className="space-y-6">
{/* Datenschutzerklärung PDF (Papierform) */}
{customer && (
<div className="border rounded-lg p-4 bg-gray-50">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-5 h-5 text-gray-400" />
<h3 className="font-medium">Datenschutzerklärung (Papierform)</h3>
{customer.privacyPolicyPath && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">Vorhanden</span>
)}
</div>
{customer.privacyPolicyPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api${customer.privacyPolicyPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Eye className="w-4 h-4" />
Anzeigen
</a>
<a
href={`/api${customer.privacyPolicyPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
<Download className="w-4 h-4" />
Download
</a>
<FileUpload
onUpload={handlePrivacyPolicyUpload}
existingFile={customer.privacyPolicyPath}
accept=".pdf"
label="Ersetzen"
/>
<button
onClick={handlePrivacyPolicyDelete}
className="text-red-600 hover:underline text-sm flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
</div>
) : (
<div>
<p className="text-sm text-gray-500 mb-2">
Unterschriebene Datenschutzerklärung als PDF hochladen. Dies gilt als vollständige Einwilligung.
</p>
<FileUpload
onUpload={handlePrivacyPolicyUpload}
accept=".pdf"
label="PDF hochladen"
/>
</div>
)}
</div>
)}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-gray-400" />
<h3 className="font-medium">Online-Einwilligungen</h3>
</div>
{/* Datenschutz senden Button */}
<div className="relative">
<Button
variant="secondary"
size="sm"
onClick={() => setShowSendDropdown(!showSendDropdown)}
>
<Mail className="w-4 h-4 mr-2" />
Datenschutz senden
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showSendDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowSendDropdown(false)} />
<div className="absolute right-0 mt-1 w-56 bg-white border rounded-lg shadow-lg z-20 py-1">
{channels.filter((ch) => ch.available).map((ch) => (
<button
key={ch.key}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center gap-2"
onClick={() => sendLinkMutation.mutate(ch.key)}
disabled={sendLinkMutation.isPending}
>
<span>{ch.icon}</span>
<span>{ch.label}</span>
</button>
))}
{channels.filter((ch) => ch.available).length === 0 && (
<p className="px-4 py-2 text-xs text-gray-400">
Kanäle in Benutzereinstellungen konfigurieren (E-Mail, WhatsApp, Telegram, Signal)
</p>
)}
</div>
</>
)}
</div>
</div>
<p className="text-sm text-gray-500 mb-4">
Einwilligungen können nur vom Kunden selbst erteilt oder widerrufen werden (Kundenportal oder Datenschutz-Link).
</p>
<div className="space-y-4">
{consents.map((consent) => {
const typeInfo = CONSENT_TYPE_LABELS[consent.consentType] || { label: consent.consentType, description: '' };
return (
<div key={consent.consentType} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{getStatusIcon(consent.status)}
<div>
<h4 className="font-medium">{typeInfo.label}</h4>
<p className="text-sm text-gray-500">{typeInfo.description}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
{consent.grantedAt && (
<span>Erteilt am: {formatDate(consent.grantedAt)}</span>
)}
{consent.withdrawnAt && (
<span>Widerrufen am: {formatDate(consent.withdrawnAt)}</span>
)}
{consent.source && (
<span>Quelle: {consent.source}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(consent.status)}
{canEdit && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={consent.status === 'GRANTED'}
onChange={() => handleToggle(consent)}
disabled={updateMutation.isPending}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-600"></div>
</label>
)}
</div>
</div>
</div>
);
})}
</div>
{consents.length === 0 && (
<p className="text-gray-500 text-center py-4">Keine Einwilligungen konfiguriert.</p>
)}
{/* Vollmachten */}
<AuthorizationsSection customerId={customerId} customerEmail={customerEmail} />
</div>
);
}
// ==================== Vollmachten-Bereich ====================
function AuthorizationsSection({ customerId, customerEmail }: { customerId: number; customerEmail?: string }) {
const queryClient = useQueryClient();
const { user } = useAuth();
const [sendDropdownFor, setSendDropdownFor] = useState<number | null>(null);
const { data: authData, isLoading } = useQuery({
queryKey: ['authorizations', customerId],
queryFn: () => gdprApi.getAuthorizations(customerId),
});
const uploadMutation = useMutation({
mutationFn: ({ representativeId, file }: { representativeId: number; file: File }) =>
gdprApi.uploadAuthorizationDocument(customerId, representativeId, file),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authorizations', customerId] }),
});
const deleteDocMutation = useMutation({
mutationFn: (representativeId: number) =>
gdprApi.deleteAuthorizationDocument(customerId, representativeId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authorizations', customerId] }),
});
const sendMutation = useMutation({
mutationFn: ({ representativeId, channel }: { representativeId: number; channel: string }) =>
gdprApi.sendAuthorizationRequest(customerId, representativeId, channel),
onSuccess: (result, { channel }) => {
const messageText = result.data?.messageText;
const portalUrl = result.data?.portalUrl;
if (channel === 'email') {
alert('Vollmacht-Anfrage wurde per E-Mail gesendet.');
} else if (channel === 'whatsapp') {
const text = encodeURIComponent(messageText || `Bitte erteilen Sie die Vollmacht: ${portalUrl}`);
window.open(`https://wa.me/?text=${text}`, '_blank');
} else if (channel === 'telegram') {
const text = encodeURIComponent(messageText || '');
window.open(`https://t.me/share/url?url=${encodeURIComponent(portalUrl || '')}&text=${text}`, '_blank');
} else if (channel === 'signal') {
const text = encodeURIComponent(messageText || '');
window.open(`signal://send?text=${text}`, '_blank');
}
setSendDropdownFor(null);
},
onError: (error) => {
alert(`Fehler beim Senden: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
setSendDropdownFor(null);
},
});
const authorizations: RepresentativeAuthorization[] = authData?.data || [];
if (isLoading) return <div className="text-center py-4 text-gray-500">Laden...</div>;
if (authorizations.length === 0) return null;
const handleFileUpload = (representativeId: number, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadMutation.mutate({ representativeId, file });
}
};
// Messaging-Kanäle
const userAny = user as any;
const channels: { key: string; label: string; icon: string; available: boolean }[] = [
{ key: 'email', label: 'Per E-Mail', icon: '✉️', available: !!customerEmail },
{ key: 'whatsapp', label: 'Per WhatsApp', icon: '💬', available: !!userAny?.whatsappNumber },
{ key: 'telegram', label: 'Per Telegram', icon: '📨', available: !!userAny?.telegramUsername },
{ key: 'signal', label: 'Per Signal', icon: '📱', available: !!userAny?.signalNumber },
];
return (
<div className="pt-6 border-t mt-6">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-5 h-5 text-gray-400" />
<h3 className="font-medium">Vollmachten (Vertreterregelung)</h3>
</div>
<p className="text-sm text-gray-500 mb-4">
Damit ein Vertreter die Verträge dieses Kunden einsehen kann, muss eine Vollmacht vorliegen.
Diese kann als PDF hochgeladen oder vom Kunden im Portal erteilt werden.
</p>
<div className="space-y-3">
{authorizations.map((auth) => (
<div key={auth.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{auth.isGranted ? (
<ShieldCheck className="w-5 h-5 text-green-500 mt-0.5" />
) : (
<ShieldAlert className="w-5 h-5 text-yellow-500 mt-0.5" />
)}
<div>
<h4 className="font-medium">
Vollmacht für: {auth.representative?.firstName} {auth.representative?.lastName}
</h4>
<p className="text-xs text-gray-500">
{auth.representative?.customerNumber && `Kd.-Nr.: ${auth.representative.customerNumber}`}
</p>
{auth.grantedAt && auth.isGranted && (
<p className="text-xs text-gray-400 mt-1">
Erteilt am: {new Date(auth.grantedAt).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})}
{auth.source && ` (${auth.source})`}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={auth.isGranted ? 'success' : 'warning'}>
{auth.isGranted ? 'Erteilt' : 'Ausstehend'}
</Badge>
{/* Senden-Button mit Dropdown */}
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setSendDropdownFor(sendDropdownFor === auth.representativeId ? null : auth.representativeId)}
>
<Mail className="w-4 h-4 mr-1" />
Senden
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{sendDropdownFor === auth.representativeId && (
<>
<div className="fixed inset-0 z-10" onClick={() => setSendDropdownFor(null)} />
<div className="absolute right-0 mt-1 w-56 bg-white border rounded-lg shadow-lg z-20 py-1">
{channels.filter((ch) => ch.available).map((ch) => (
<button
key={ch.key}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center gap-2"
onClick={() => sendMutation.mutate({ representativeId: auth.representativeId, channel: ch.key })}
disabled={sendMutation.isPending}
>
<span>{ch.icon}</span>
<span>{ch.label}</span>
</button>
))}
{channels.filter((ch) => ch.available).length === 0 && (
<p className="px-4 py-2 text-xs text-gray-400">
Kanäle in Benutzereinstellungen konfigurieren
</p>
)}
</div>
</>
)}
</div>
</div>
</div>
{/* Dokument-Upload */}
<div className="mt-3 pt-3 border-t flex items-center gap-3">
{auth.documentPath ? (
<>
<a
href={`/api${auth.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs flex items-center gap-1"
>
<Eye className="w-3 h-3" />
Vollmacht-PDF anzeigen
</a>
<button
onClick={() => deleteDocMutation.mutate(auth.representativeId)}
className="text-red-600 hover:underline text-xs flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
Löschen
</button>
</>
) : (
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
<Plus className="w-3 h-3" />
Vollmacht-PDF hochladen
<input
type="file"
accept=".pdf"
className="hidden"
onChange={(e) => handleFileUpload(auth.representativeId, e)}
/>
</label>
)}
</div>
</div>
))}
</div>
</div>
);
}