opencrm/frontend/src/pages/customers/CustomerDetail.tsx

3407 lines
117 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, StressfreiEmail } 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 } from 'lucide-react';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
export default function CustomerDetail() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const [searchParams] = useSearchParams();
const customerId = parseInt(id!);
const defaultTab = searchParams.get('tab') || 'addresses';
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', id],
queryFn: () => customerApi.getById(customerId),
});
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;
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: (
<BankCardsTab
customerId={customerId}
bankCards={c.bankCards || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowBankCardModal(true)}
onEdit={(card) => setEditingBankCard(card)}
/>
),
},
{
id: 'documents',
label: 'Ausweise',
content: (
<DocumentsTab
customerId={customerId}
documents={c.identityDocuments || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowDocumentModal(true)}
onEdit={(doc) => setEditingDocument(doc)}
/>
),
},
{
id: 'meters',
label: 'Zähler',
content: (
<MetersTab
customerId={customerId}
meters={c.meters || []}
canEdit={hasPermission('customers:update')}
showInactive={showInactive}
onToggleInactive={() => setShowInactive(!showInactive)}
onAdd={() => setShowMeterModal(true)}
onEdit={(meter) => setEditingMeter(meter)}
/>
),
},
{
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: (
<EmailClientTab customerId={customerId} />
),
},
{
id: 'contracts',
label: 'Verträge',
content: (
<ContractsTab
customerId={customerId}
contracts={c.contracts || []}
/>
),
},
...(hasPermission('customers:update') ? [{
id: 'portal',
label: 'Portal',
content: (
<PortalTab
customerId={customerId}
canEdit={hasPermission('customers:update')}
/>
),
}] : []),
];
return (
<div>
<div className="flex items-center justify-between mb-6">
<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 className="flex gap-2">
{hasPermission('customers:update') && (
<Link to={`/customers/${id}/edit`}>
<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">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', id] })}
/>
)}
{/* Dokumente Card - für ALLE Kunden */}
<CustomerDocumentsCard
customer={c}
canEdit={hasPermission('customers:update')}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['customer', id] })}
/>
{c.notes && (
<Card title="Notizen" className="mb-6">
<p className="whitespace-pre-wrap">{c.notes}</p>
</Card>
)}
<Card>
<Tabs tabs={tabs} defaultTab={defaultTab} />
</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>
);
}
// Customer Documents Card (für alle Kunden - Datenschutzerklärung)
function CustomerDocumentsCard({
customer,
canEdit,
onUpdate,
}: {
customer: Customer;
canEdit: boolean;
onUpdate: () => void;
}) {
const handlePrivacyPolicyUpload = async (file: File) => {
try {
await uploadApi.uploadPrivacyPolicy(customer.id, file);
onUpdate();
} 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(customer.id);
onUpdate();
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
}
};
// Nur anzeigen wenn Dokument vorhanden oder Bearbeitung möglich
if (!customer.privacyPolicyPath && !canEdit) return null;
return (
<Card title="Dokumente" className="mb-6">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Datenschutzerklärung</h4>
{customer.privacyPolicyPath ? (
<div className="flex items-center gap-2 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>
{canEdit && (
<>
<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>
) : canEdit ? (
<FileUpload
onUpload={handlePrivacyPolicyUpload}
accept=".pdf"
label="PDF hochladen"
/>
) : (
<p className="text-sm text-gray-400">Nicht vorhanden</p>
)}
</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.toString()] }),
});
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>
</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.toString()] }),
});
const deleteMutation = useMutation({
mutationFn: bankCardApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
});
const handleDocumentUpload = async (cardId: number, file: File) => {
try {
await uploadApi.uploadBankCardDocument(cardId, file);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
} 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.toString()] });
} 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: {new Date(card.expiryDate).toLocaleDateString('de-DE')}
</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.toString()] }),
});
const deleteMutation = useMutation({
mutationFn: documentApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
});
const handleDocumentUpload = async (docId: number, file: File) => {
try {
await uploadApi.uploadIdentityDocument(docId, file);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
} 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.toString()] });
} 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<number | null>(null);
const [expandedMeter, setExpandedMeter] = useState<number | null>(null);
const [editingReading, setEditingReading] = useState<{ meterId: number; 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.toString()] }),
});
const deleteMutation = useMutation({
mutationFn: meterApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
});
const deleteReadingMutation = useMutation({
mutationFn: ({ meterId, readingId }: { meterId: number; readingId: number }) =>
meterApi.deleteReading(meterId, readingId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
});
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.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{canEdit && (
<div className="flex gap-1">
{meter.isActive && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReadingModal(meter.id)}
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">
{new Date(reading.readingDate).toLocaleDateString('de-DE')}
<CopyButton value={new Date(reading.readingDate).toLocaleDateString('de-DE')} />
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{reading.value.toLocaleString('de-DE')} {reading.unit}
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
<CopyButton value={`${reading.value.toLocaleString('de-DE')} ${reading.unit}`} title="Mit Einheit kopieren" />
</span>
{canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<button
onClick={() => setEditingReading({ meterId: meter.id, 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}
customerId={customerId}
/>
)}
{editingReading && (
<MeterReadingModal
isOpen={true}
onClose={() => setEditingReading(null)}
meterId={editingReading.meterId}
customerId={customerId}
reading={editingReading.reading}
/>
)}
</div>
);
}
function ContractsTab({
customerId,
contracts,
}: {
customerId: number;
contracts: any[];
}) {
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: contractApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
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',
};
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>
)}
{contracts.length > 0 ? (
<div className="space-y-4">
{contracts.map((contract) => (
<div
key={contract.id}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</span>
<Badge>{typeLabels[contract.type]}</Badge>
<Badge variant={statusVariants[contract.status]}>{contract.status}</Badge>
</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 && (
<p className="flex items-center gap-1">
{contract.providerName}
{contract.tariffName && ` - ${contract.tariffName}`}
<CopyButton value={contract.providerName + (contract.tariffName ? ` - ${contract.tariffName}` : '')} />
</p>
)}
{contract.startDate && (
<p className="text-sm text-gray-500">
Beginn: {new Date(contract.startDate).toLocaleDateString('de-DE')}
{contract.endDate &&
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
</p>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500">Keine Verträge vorhanden.</p>
)}
</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,
});
const [formData, setFormData] = useState(getInitialFormData);
const createMutation = useMutation({
mutationFn: (data: typeof formData) => addressApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
onClose();
setFormData({
type: 'DELIVERY_RESIDENCE',
street: '',
houseNumber: '',
postalCode: '',
city: '',
country: 'Deutschland',
isDefault: false,
});
},
});
const updateMutation = useMutation({
mutationFn: (data: typeof formData) => addressApi.update(address!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
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>
<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.toString()] });
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.toString()] });
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.toString()] });
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.toString()] });
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,
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.toString()] });
onClose();
setFormData({ meterNumber: '', type: 'ELECTRICITY', location: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.update(meter!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
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' },
]}
/>
<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,
customerId,
reading,
}: {
isOpen: boolean;
onClose: () => void;
meterId: number;
customerId: number;
reading?: { id: number; readingDate: string; value: number; unit: string; notes?: string } | null;
}) {
const queryClient = useQueryClient();
const isEditing = !!reading;
const getInitialFormData = () => ({
readingDate: reading?.readingDate
? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '',
unit: reading?.unit || 'kWh',
notes: reading?.notes || '',
});
const [formData, setFormData] = useState(getInitialFormData);
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value),
unit: formData.unit,
notes: formData.notes || undefined,
};
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 grid-cols-3 gap-4">
<div className="col-span-2">
<Input
label="Zählerstand"
type="number"
step="0.01"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
required
/>
</div>
<Select
label="Einheit"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
options={[
{ value: 'kWh', label: 'kWh' },
{ value: 'm³', label: 'm³' },
]}
/>
</div>
<Input
label="Notizen"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Optionale Notizen..."
/>
<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.toString()] }),
});
const deleteMutation = useMutation({
mutationFn: stressfreiEmailApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
});
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.toString()] });
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.toString()] });
}
}
} 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.toString()] });
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.toString()] });
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>
);
}