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:
2026-03-21 11:59:53 +01:00
parent 89cf92eaf5
commit f2876f877e
1491 changed files with 265550 additions and 1292 deletions
@@ -0,0 +1,120 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { gdprApi } from '../../services/api';
import type { RepresentativeAuthorization } from '../../types';
import {
FileCheck,
ShieldCheck,
ShieldAlert,
FileText,
} from 'lucide-react';
import Card from '../../components/ui/Card';
export default function PortalAuthorizations() {
const queryClient = useQueryClient();
const { data: authData, isLoading } = useQuery({
queryKey: ['my-authorizations'],
queryFn: () => gdprApi.getMyAuthorizations(),
});
const toggleMutation = useMutation({
mutationFn: ({ representativeId, grant }: { representativeId: number; grant: boolean }) =>
gdprApi.toggleMyAuthorization(representativeId, grant),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-authorizations'] });
queryClient.invalidateQueries({ queryKey: ['my-authorization-status'] });
},
});
const authorizations: RepresentativeAuthorization[] = authData?.data || [];
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Laden...</div>;
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<FileCheck className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold">Vollmachten</h1>
</div>
<Card className="mb-6">
<p className="text-sm text-gray-600">
Hier können Sie verwalten, welche Vertreter Zugriff auf Ihre Verträge und Daten haben.
Ohne Ihre Vollmacht kann ein Vertreter Ihre Daten nicht einsehen.
</p>
</Card>
{authorizations.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
Keine Vollmachten vorhanden.
</div>
</Card>
) : (
<div className="space-y-3">
{authorizations.map((auth) => (
<Card key={auth.id}>
<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">
{auth.representative?.firstName} {auth.representative?.lastName}
</h4>
<p className="text-sm text-gray-500 mt-0.5">
{auth.isGranted
? 'Hat Vollmacht, Ihre Daten einzusehen'
: 'Keine Vollmacht kann Ihre Daten nicht einsehen'
}
</p>
{auth.documentPath && (
<p className="text-xs text-blue-600 mt-1 flex items-center gap-1">
<FileText className="w-3 h-3" />
Vollmacht liegt als Dokument vor
</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', hour: '2-digit', minute: '2-digit',
})}
</p>
)}
</div>
</div>
<button
onClick={() => toggleMutation.mutate({
representativeId: auth.representativeId,
grant: !auth.isGranted,
})}
disabled={toggleMutation.isPending}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
auth.isGranted ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
auth.isGranted ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</Card>
))}
</div>
)}
{toggleMutation.isError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Fehler beim Speichern. Bitte versuchen Sie es erneut.
</div>
)}
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { meterApi } from '../../services/api';
import type { Meter } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge';
import { Gauge, Zap, Flame, Plus, Check, ChevronDown, ChevronRight } from 'lucide-react';
export default function PortalMeters() {
const queryClient = useQueryClient();
const [expandedMeter, setExpandedMeter] = useState<number | null>(null);
const [reportingMeter, setReportingMeter] = useState<number | null>(null);
const [readingValue, setReadingValue] = useState('');
const [readingDate, setReadingDate] = useState(new Date().toISOString().split('T')[0]);
const [readingNotes, setReadingNotes] = useState('');
const { data: metersData, isLoading } = useQuery({
queryKey: ['my-meters'],
queryFn: () => meterApi.getMyMeters(),
});
const reportMutation = useMutation({
mutationFn: ({ meterId, data }: { meterId: number; data: { value: number; readingDate?: string; notes?: string } }) =>
meterApi.reportReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-meters'] });
setReportingMeter(null);
setReadingValue('');
setReadingNotes('');
setReadingDate(new Date().toISOString().split('T')[0]);
},
});
const meters: Meter[] = metersData?.data || [];
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Laden...</div>;
}
const handleReport = (meterId: number) => {
const value = parseFloat(readingValue);
if (isNaN(value) || value < 0) {
alert('Bitte geben Sie einen gültigen Zählerstand ein.');
return;
}
reportMutation.mutate({
meterId,
data: {
value,
readingDate,
notes: readingNotes || undefined,
},
});
};
const formatDate = (date: string) =>
new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Gauge className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold">Meine Zähler</h1>
</div>
<Card className="mb-6">
<p className="text-sm text-gray-600">
Hier können Sie Ihre Zählerstände melden. Wir übertragen den Stand dann an Ihren Anbieter.
</p>
</Card>
{meters.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
Keine Zähler vorhanden.
</div>
</Card>
) : (
<div className="space-y-4">
{meters.map((meter) => {
const isExpanded = expandedMeter === meter.id;
const isReporting = reportingMeter === meter.id;
const TypeIcon = meter.type === 'ELECTRICITY' ? Zap : Flame;
const lastReading = meter.readings?.[0];
return (
<Card key={meter.id}>
{/* Zähler-Header */}
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setExpandedMeter(isExpanded ? null : meter.id)}
>
<div className="flex items-center gap-3">
<TypeIcon className={`w-5 h-5 ${meter.type === 'ELECTRICITY' ? 'text-yellow-500' : 'text-blue-500'}`} />
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium font-mono">{meter.meterNumber}</h3>
<Badge variant={meter.type === 'ELECTRICITY' ? 'warning' : 'info'}>
{meter.type === 'ELECTRICITY' ? 'Strom' : 'Gas'}
</Badge>
</div>
{meter.location && (
<p className="text-xs text-gray-500">{meter.location}</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
{lastReading && (
<span className="text-sm text-gray-500">
Letzter Stand: {lastReading.value} {lastReading.unit} ({formatDate(lastReading.readingDate)})
</span>
)}
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
</div>
{/* Erweiterte Ansicht */}
{isExpanded && (
<div className="mt-4 pt-4 border-t">
{/* Zählerstand melden Button */}
{!isReporting ? (
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); setReportingMeter(meter.id); }}
className="mb-4"
>
<Plus className="w-4 h-4 mr-2" />
Zählerstand melden
</Button>
) : (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-800 mb-3">Zählerstand melden</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="Zählerstand *"
type="number"
step="0.01"
min="0"
value={readingValue}
onChange={(e) => setReadingValue(e.target.value)}
placeholder={`z.B. 12345 ${meter.type === 'ELECTRICITY' ? 'kWh' : 'm³'}`}
/>
<Input
label="Ablesedatum"
type="date"
value={readingDate}
onChange={(e) => setReadingDate(e.target.value)}
/>
<Input
label="Notiz (optional)"
value={readingNotes}
onChange={(e) => setReadingNotes(e.target.value)}
placeholder="z.B. Jahresablesung"
/>
</div>
<div className="flex gap-2 mt-3">
<Button
size="sm"
onClick={() => handleReport(meter.id)}
disabled={reportMutation.isPending || !readingValue}
>
<Check className="w-4 h-4 mr-1" />
{reportMutation.isPending ? 'Sende...' : 'Melden'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setReportingMeter(null)}
>
Abbrechen
</Button>
</div>
{reportMutation.isError && (
<p className="text-xs text-red-600 mt-2">Fehler beim Melden. Bitte erneut versuchen.</p>
)}
</div>
)}
{/* Letzte Ablesungen */}
{meter.readings && meter.readings.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Ablesungen</h4>
<div className="space-y-1">
{meter.readings.map((reading) => (
<div key={reading.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded text-sm">
<span className="text-gray-600">{formatDate(reading.readingDate)}</span>
<div className="flex items-center gap-3">
<span className="font-medium">{reading.value} {reading.unit}</span>
{reading.status === 'REPORTED' && (
<Badge variant="warning">Gemeldet</Badge>
)}
{reading.status === 'TRANSFERRED' && (
<Badge variant="success">Übertragen</Badge>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Card>
);
})}
</div>
)}
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../context/AuthContext';
import { gdprApi } from '../../services/api';
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
import {
Shield,
ShieldCheck,
ShieldX,
ShieldAlert,
FileDown,
CheckCircle2,
} from 'lucide-react';
import Card from '../../components/ui/Card';
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 an Partner',
description: 'Weitergabe Ihrer Daten an ausgewählte Partnerunternehmen',
},
};
export default function PortalPrivacy() {
const { user } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['my-privacy'],
queryFn: () => gdprApi.getMyPrivacy(),
});
const updateMutation = useMutation({
mutationFn: ({ consentType, status }: { consentType: ConsentType; status: ConsentStatus }) =>
gdprApi.updateConsent(user!.customerId!, consentType, { status, source: 'portal' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
queryClient.invalidateQueries({ queryKey: ['my-consent-status'] });
},
});
const handleToggle = (consent: CustomerConsent) => {
const newStatus: ConsentStatus = consent.status === 'GRANTED' ? 'WITHDRAWN' : 'GRANTED';
updateMutation.mutate({ consentType: consent.consentType, status: newStatus });
};
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 getStatusLabel = (status: ConsentStatus) => {
switch (status) {
case 'GRANTED':
return <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">Erteilt</span>;
case 'WITHDRAWN':
return <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">Widerrufen</span>;
case 'PENDING':
return <span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Ausstehend</span>;
default:
return null;
}
};
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Laden...</div>;
}
const consents = data?.data?.consents || [];
const privacyPolicyHtml = data?.data?.privacyPolicyHtml || '';
const allGranted = consents.every((c) => c.status === 'GRANTED');
const token = localStorage.getItem('token');
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Shield className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold">Datenschutz</h1>
</div>
{/* Erfolgs-Banner wenn alle erteilt */}
{allGranted && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6 flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
<p className="text-sm text-green-700">
Sie haben allen Einwilligungen zugestimmt. Vielen Dank!
</p>
</div>
)}
{/* Einwilligungen */}
<Card title="Ihre Einwilligungen" className="mb-6">
<p className="text-sm text-gray-500 mb-4">
Hier können Sie Ihre Datenschutz-Einwilligungen verwalten. Alle Einwilligungen sind erforderlich, damit wir Sie beraten können.
</p>
<div className="space-y-3">
{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>
<div className="flex items-center gap-2">
<h4 className="font-medium">{typeInfo.label}</h4>
{getStatusLabel(consent.status)}
</div>
<p className="text-sm text-gray-500 mt-0.5">{typeInfo.description}</p>
{consent.grantedAt && consent.status === 'GRANTED' && (
<p className="text-xs text-gray-400 mt-1">
Erteilt am {new Date(consent.grantedAt).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</p>
)}
</div>
</div>
<button
onClick={() => handleToggle(consent)}
disabled={updateMutation.isPending}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
consent.status === 'GRANTED'
? 'bg-green-500'
: 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
consent.status === 'GRANTED' ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
);
})}
</div>
{updateMutation.isError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Fehler beim Speichern. Bitte versuchen Sie es erneut.
</div>
)}
</Card>
{/* Datenschutzerklärung */}
<Card title="Datenschutzerklärung" className="mb-6">
<div className="flex justify-end mb-4">
<a
href={`${gdprApi.getMyPrivacyPdfUrl}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<FileDown className="w-4 h-4" />
Als PDF herunterladen
</a>
</div>
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
/>
</Card>
</div>
);
}
@@ -0,0 +1,14 @@
import { useAuth } from '../../context/AuthContext';
import CustomerDetail from '../customers/CustomerDetail';
export default function PortalProfile() {
const { user } = useAuth();
if (!user?.customerId) {
return <div className="text-center py-8 text-gray-500">Keine Kundendaten verfügbar.</div>;
}
// CustomerDetail rendert sich basierend auf der URL-Parameter :id
// Wir leiten direkt weiter auf die richtige Kunden-URL
return <CustomerDetail portalCustomerId={user.customerId} />;
}