gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
|
||||
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';
|
||||
@@ -12,18 +12,25 @@ 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 } from 'lucide-react';
|
||||
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft } from 'lucide-react';
|
||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
|
||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
|
||||
|
||||
export default function CustomerDetail() {
|
||||
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const customerId = parseInt(id!);
|
||||
const { hasPermission, isCustomerPortal } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const customerId = portalCustomerId || parseInt(id!);
|
||||
const defaultTab = searchParams.get('tab') || 'addresses';
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
// Tab-Wechsel in URL synchronisieren (für Browser-History)
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
setSearchParams({ tab: tabId }, { replace: true });
|
||||
};
|
||||
|
||||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||||
const [showBankCardModal, setShowBankCardModal] = useState(false);
|
||||
@@ -38,10 +45,19 @@ export default function CustomerDetail() {
|
||||
const [editingStressfreiEmail, setEditingStressfreiEmail] = useState<StressfreiEmail | null>(null);
|
||||
|
||||
const { data: customer, isLoading } = useQuery({
|
||||
queryKey: ['customer', id],
|
||||
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: () => {
|
||||
@@ -59,6 +75,11 @@ export default function CustomerDetail() {
|
||||
|
||||
const c = customer.data;
|
||||
|
||||
// Gesperrter Inhalt für Tabs ohne Einwilligung
|
||||
const blockedContent = (
|
||||
<ConsentBlockedContent onGoToConsentsTab={() => handleTabChange('consents')} />
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'addresses',
|
||||
@@ -76,7 +97,7 @@ export default function CustomerDetail() {
|
||||
{
|
||||
id: 'bankcards',
|
||||
label: 'Bankkarten',
|
||||
content: (
|
||||
content: hasConsentApproval ? (
|
||||
<BankCardsTab
|
||||
customerId={customerId}
|
||||
bankCards={c.bankCards || []}
|
||||
@@ -86,12 +107,12 @@ export default function CustomerDetail() {
|
||||
onAdd={() => setShowBankCardModal(true)}
|
||||
onEdit={(card) => setEditingBankCard(card)}
|
||||
/>
|
||||
),
|
||||
) : blockedContent,
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Ausweise',
|
||||
content: (
|
||||
content: hasConsentApproval ? (
|
||||
<DocumentsTab
|
||||
customerId={customerId}
|
||||
documents={c.identityDocuments || []}
|
||||
@@ -101,12 +122,12 @@ export default function CustomerDetail() {
|
||||
onAdd={() => setShowDocumentModal(true)}
|
||||
onEdit={(doc) => setEditingDocument(doc)}
|
||||
/>
|
||||
),
|
||||
) : blockedContent,
|
||||
},
|
||||
{
|
||||
id: 'meters',
|
||||
label: 'Zähler',
|
||||
content: (
|
||||
content: hasConsentApproval ? (
|
||||
<MetersTab
|
||||
customerId={customerId}
|
||||
meters={c.meters || []}
|
||||
@@ -116,9 +137,9 @@ export default function CustomerDetail() {
|
||||
onAdd={() => setShowMeterModal(true)}
|
||||
onEdit={(meter) => setEditingMeter(meter)}
|
||||
/>
|
||||
),
|
||||
) : blockedContent,
|
||||
},
|
||||
{
|
||||
...(!isCustomerPortal ? [{
|
||||
id: 'stressfrei',
|
||||
label: 'Stressfrei-Wechseln',
|
||||
content: (
|
||||
@@ -132,22 +153,22 @@ export default function CustomerDetail() {
|
||||
onEdit={(email) => setEditingStressfreiEmail(email)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
id: 'emails',
|
||||
label: 'E-Mail-Postfach',
|
||||
content: (
|
||||
content: hasConsentApproval ? (
|
||||
<EmailClientTab customerId={customerId} />
|
||||
),
|
||||
) : blockedContent,
|
||||
},
|
||||
{
|
||||
id: 'contracts',
|
||||
label: 'Verträge',
|
||||
content: (
|
||||
content: hasConsentApproval ? (
|
||||
<ContractsTab
|
||||
customerId={customerId}
|
||||
/>
|
||||
),
|
||||
) : blockedContent,
|
||||
},
|
||||
...(hasPermission('customers:update') ? [{
|
||||
id: 'portal',
|
||||
@@ -159,21 +180,42 @@ export default function CustomerDetail() {
|
||||
/>
|
||||
),
|
||||
}] : []),
|
||||
...(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>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{c.type === 'BUSINESS' && c.companyName
|
||||
? c.companyName
|
||||
: `${c.firstName} ${c.lastName}`}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
||||
<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') && (
|
||||
@@ -316,17 +358,10 @@ export default function CustomerDetail() {
|
||||
<BusinessDataCard
|
||||
customer={c}
|
||||
canEdit={hasPermission('customers:update')}
|
||||
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['customer', id] })}
|
||||
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['customer', customerId] })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dokumente Card - für ALLE Kunden */}
|
||||
<CustomerDocumentsCard
|
||||
customer={c}
|
||||
canEdit={hasPermission('customers:update')}
|
||||
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['customer', id] })}
|
||||
/>
|
||||
|
||||
{c.notes && (
|
||||
<Card title="Notizen" className="mb-6">
|
||||
<p className="whitespace-pre-wrap">{c.notes}</p>
|
||||
@@ -334,7 +369,7 @@ export default function CustomerDetail() {
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Tabs tabs={tabs} defaultTab={defaultTab} />
|
||||
<Tabs tabs={tabs} defaultTab={defaultTab} activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</Card>
|
||||
|
||||
<AddressModal
|
||||
@@ -595,95 +630,6 @@ function BusinessDataCard({
|
||||
);
|
||||
}
|
||||
|
||||
// Customer Documents Card (für alle Kunden - Datenschutzerklärung)
|
||||
function CustomerDocumentsCard({
|
||||
customer,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
customer: Customer;
|
||||
canEdit: boolean;
|
||||
onUpdate: () => void;
|
||||
}) {
|
||||
const handlePrivacyPolicyUpload = async (file: File) => {
|
||||
try {
|
||||
await uploadApi.uploadPrivacyPolicy(customer.id, file);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Upload fehlgeschlagen:', error);
|
||||
alert('Upload fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacyPolicyDelete = async () => {
|
||||
if (!confirm('Datenschutzerklärung wirklich löschen?')) return;
|
||||
try {
|
||||
await uploadApi.deletePrivacyPolicy(customer.id);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Löschen fehlgeschlagen:', error);
|
||||
alert('Löschen fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
// Nur anzeigen wenn Dokument vorhanden oder Bearbeitung möglich
|
||||
if (!customer.privacyPolicyPath && !canEdit) return null;
|
||||
|
||||
return (
|
||||
<Card title="Dokumente" className="mb-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Datenschutzerklärung</h4>
|
||||
{customer.privacyPolicyPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/api${customer.privacyPolicyPath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${customer.privacyPolicyPath}`}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
{canEdit && (
|
||||
<>
|
||||
<FileUpload
|
||||
onUpload={handlePrivacyPolicyUpload}
|
||||
existingFile={customer.privacyPolicyPath}
|
||||
accept=".pdf"
|
||||
label="Ersetzen"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePrivacyPolicyDelete}
|
||||
className="text-red-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<FileUpload
|
||||
onUpload={handlePrivacyPolicyUpload}
|
||||
accept=".pdf"
|
||||
label="PDF hochladen"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Nicht vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab Components
|
||||
function AddressesTab({
|
||||
customerId,
|
||||
@@ -1601,10 +1547,10 @@ function ContractsTab({
|
||||
<div className="w-6" /> // Platzhalter für Ausrichtung
|
||||
) : null}
|
||||
|
||||
<span className="font-mono flex items-center gap-1">
|
||||
<Link to={`/contracts/${contract.id}`} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
|
||||
{contract.contractNumber}
|
||||
<CopyButton value={contract.contractNumber} />
|
||||
</span>
|
||||
</Link>
|
||||
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
||||
<Badge variant={statusVariants[contract.status] || 'default'}>{contract.status}</Badge>
|
||||
{depth === 0 && !isPredecessor && (
|
||||
@@ -3524,3 +3470,568 @@ function StressfreiEmailModal({
|
||||
</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: 'E-Mail-Marketing',
|
||||
description: 'Zusendung von Werbung und Angeboten per E-Mail',
|
||||
},
|
||||
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?.();
|
||||
} 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?.();
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user