cdde7b4ab7
Alle hardcoded Referenzen auf 'stressfrei-wechseln.de' und 'Stressfrei-Wechseln'
durch dynamische Werte aus der EmailProviderConfig ersetzt. Notwendig für
Multi-Mandanten-Betrieb, wenn das CRM an Dritte vermietet wird.
Schema:
- Neues Feld EmailProviderConfig.customerEmailLabel (String?)
- Wenn leer, wird Label aus Domain abgeleitet ('stressfrei-wechseln.de' → 'Stressfrei-Wechseln')
Backend:
- Neuer Endpoint GET /api/email-providers/public-settings liefert { domain, customerEmailLabel }
- Neue Service-Funktionen: getProviderPublicSettings(), deriveLabelFromDomain()
- create/updateProviderConfig erweitert um customerEmailLabel
Frontend:
- Neuer Hook useProviderSettings() mit Auto-Caching
- Neues Eingabefeld 'Bezeichnung für Kunden-E-Mails' im Provider-Modal
- Dynamische Domain-Suffix im Adress-Hinzufügen-Dialog (@<domain>)
- Tab-Label 'Stressfrei-Wechseln' im Kunden-Detail → dynamisch
- 'Stressfrei-Wechseln Adresse' in ContractForm → dynamisch
- '(Stressfrei-Wechseln)' Badge in ContractDetail → dynamisch
- 'Stressfrei-Wechseln E-Mail' im Generate-Modal → dynamisch
- Leere-Zustand-Meldungen in Tab und E-Mail-Client → dynamisch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4269 lines
154 KiB
TypeScript
4269 lines
154 KiB
TypeScript
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, Cake } from 'lucide-react';
|
||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
||
import { formatDate } from '../../utils/dateFormat';
|
||
import { getContractTypeInfo } from '../../utils/contractInfo';
|
||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
|
||
|
||
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { hasPermission, isCustomerPortal } = useAuth();
|
||
const location = useLocation();
|
||
const back = popHistory(location.state, isCustomerPortal ? '/' : '/customers');
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const customerId = portalCustomerId || parseInt(id!);
|
||
const defaultTab = searchParams.get('tab') || 'addresses';
|
||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||
const { customerEmailLabel } = useProviderSettings();
|
||
|
||
// Tab-Wechsel in URL synchronisieren (für Browser-History)
|
||
const handleTabChange = (tabId: string) => {
|
||
setActiveTab(tabId);
|
||
setSearchParams({ tab: tabId }, { replace: true });
|
||
};
|
||
|
||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||
const [showBankCardModal, setShowBankCardModal] = useState(false);
|
||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||
const [showMeterModal, setShowMeterModal] = useState(false);
|
||
const [showStressfreiEmailModal, setShowStressfreiEmailModal] = useState(false);
|
||
const [showBirthdayModal, setShowBirthdayModal] = useState(false);
|
||
const [showInactive, setShowInactive] = useState(false);
|
||
const [editingBankCard, setEditingBankCard] = useState<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: customerEmailLabel,
|
||
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' })} />
|
||
{!isCustomerPortal && hasPermission('customers:update') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowBirthdayModal(true)}
|
||
className="ml-1 p-1 hover:bg-pink-50 rounded transition-colors"
|
||
title="Geburtstag verwalten"
|
||
>
|
||
<Cake className="w-4 h-4 text-pink-500" />
|
||
</button>
|
||
)}
|
||
</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}
|
||
/>
|
||
|
||
{showBirthdayModal && c && (
|
||
<BirthdayManagementModal
|
||
customer={c}
|
||
onClose={() => setShowBirthdayModal(false)}
|
||
/>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// ==================== KUNDEN-E-MAIL TAB ====================
|
||
|
||
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 { customerEmailLabel } = useProviderSettings();
|
||
|
||
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 {customerEmailLabel} 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);
|
||
|
||
// Domain dynamisch vom Provider (mit Fallback)
|
||
const { domain: providerDomain } = useProviderSettings();
|
||
const domainSuffix = `@${providerDomain || 'stressfrei-wechseln.de'}`;
|
||
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 + domainSuffix;
|
||
|
||
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">
|
||
{domainSuffix}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Vollständige Adresse: <span className="font-mono">{localPart || '...'}{domainSuffix}</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>
|
||
);
|
||
}
|