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
+78 -2
View File
@@ -1,5 +1,8 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from './context/AuthContext';
import { gdprApi } from './services/api';
import { Shield } from 'lucide-react';
import ScrollToTop from './components/ScrollToTop';
import Layout from './components/layout/Layout';
import Login from './pages/Login';
@@ -22,9 +25,19 @@ import PortalSettings from './pages/settings/PortalSettings';
import DeadlineSettings from './pages/settings/DeadlineSettings';
import EmailProviders from './pages/settings/EmailProviders';
import DatabaseBackup from './pages/settings/DatabaseBackup';
import AuditLogs from './pages/settings/AuditLogs';
import EmailLogPage from './pages/settings/EmailLogs';
import GDPRDashboard from './pages/settings/GDPRDashboard';
import UserList from './pages/users/UserList';
import Settings from './pages/Settings';
import DatabaseStructure from './pages/developer/DatabaseStructure';
import ConsentPage from './pages/public/ConsentPage';
import PrivacyPolicyEditor from './pages/settings/PrivacyPolicyEditor';
import PortalPrivacy from './pages/portal/PortalPrivacy';
import AuthorizationTemplateEditor from './pages/settings/AuthorizationTemplateEditor';
import PortalAuthorizations from './pages/portal/PortalAuthorizations';
import PortalProfile from './pages/portal/PortalProfile';
import PortalMeters from './pages/portal/PortalMeters';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@@ -44,6 +57,55 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function PortalConsentGate({ children }: { children: React.ReactNode }) {
const { isCustomerPortal } = useAuth();
// Nicht-Portal-Benutzer werden nicht eingeschränkt
if (!isCustomerPortal) return <>{children}</>;
// Portal-Kunden: Consent-Check läuft über Layout ConsentBanner + hier
return <PortalConsentCheck>{children}</PortalConsentCheck>;
}
function PortalConsentCheck({ children }: { children: React.ReactNode }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['my-consent-status'],
queryFn: () => gdprApi.getMyConsentStatus(),
staleTime: 30_000,
retry: 1,
});
if (isLoading) return <div className="text-center py-8 text-gray-500">Laden...</div>;
// Bei Fehler oder fehlender Einwilligung: sperren
const hasConsent = (!isError && data?.data?.hasConsent) ?? false;
if (!hasConsent) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="bg-amber-50 border border-amber-200 rounded-full p-4 mb-4">
<Shield className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">
Datenschutz-Einwilligung erforderlich
</h2>
<p className="text-sm text-gray-600 mb-6 max-w-md">
Dieser Bereich ist erst verfügbar, wenn Sie unserer Datenschutzerklärung zugestimmt haben.
</p>
<a
href="/privacy"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Jetzt zur Datenschutzerklärung
</a>
</div>
);
}
return <>{children}</>;
}
function DeveloperRoute({ children }: { children: React.ReactNode }) {
const { hasPermission, developerMode } = useAuth();
@@ -70,6 +132,9 @@ function App() {
<>
<ScrollToTop />
<Routes>
{/* Öffentliche Routes (OHNE Authentifizierung) */}
<Route path="/datenschutz/:hash" element={<ConsentPage />} />
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
@@ -92,15 +157,21 @@ function App() {
<Route path="customers/:id/edit" element={<CustomerForm />} />
{/* Contracts */}
<Route path="contracts" element={<ContractList />} />
<Route path="contracts" element={<PortalConsentGate><ContractList /></PortalConsentGate>} />
<Route path="contracts/cockpit" element={<ContractCockpit />} />
<Route path="contracts/new" element={<ContractForm />} />
<Route path="contracts/:id" element={<ContractDetail />} />
<Route path="contracts/:id" element={<PortalConsentGate><ContractDetail /></PortalConsentGate>} />
<Route path="contracts/:id/edit" element={<ContractForm />} />
{/* Tasks / Support-Anfragen */}
<Route path="tasks" element={<TaskList />} />
{/* Portal: Meine Daten, Datenschutz, Vollmachten */}
<Route path="my-profile" element={<PortalProfile />} />
<Route path="my-meters" element={<PortalMeters />} />
<Route path="privacy" element={<PortalPrivacy />} />
<Route path="authorizations" element={<PortalAuthorizations />} />
{/* Settings */}
<Route path="settings" element={<Settings />} />
<Route path="settings/users" element={<UserList />} />
@@ -114,6 +185,11 @@ function App() {
<Route path="settings/deadlines" element={<DeadlineSettings />} />
<Route path="settings/email-providers" element={<EmailProviders />} />
<Route path="settings/database-backup" element={<DatabaseBackup />} />
<Route path="settings/audit-logs" element={<AuditLogs />} />
<Route path="settings/email-logs" element={<EmailLogPage />} />
<Route path="settings/gdpr" element={<GDPRDashboard />} />
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
{/* Redirect old users route */}
<Route path="users" element={<Navigate to="/settings/users" replace />} />
+46 -4
View File
@@ -1,14 +1,56 @@
import { Outlet } from 'react-router-dom';
import { Outlet, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../../context/AuthContext';
import { gdprApi } from '../../services/api';
import Sidebar from './Sidebar';
import ScrollToTopButton from '../ScrollToTopButton';
import { AlertTriangle, ArrowRight } from 'lucide-react';
function ConsentBanner() {
const { user, isCustomerPortal } = useAuth();
const { data } = useQuery({
queryKey: ['my-consent-status'],
queryFn: () => gdprApi.getMyConsentStatus(),
enabled: isCustomerPortal && !!user?.customerId,
staleTime: 30_000,
});
if (!isCustomerPortal || !data?.data) return null;
if (data.data.hasConsent) return null;
return (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0" />
<p className="text-sm text-amber-800">
Bitte stimmen Sie unserer Datenschutzerklärung zu, damit wir Sie beraten können.
</p>
</div>
<Link
to="/privacy"
className="flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-900 whitespace-nowrap"
>
Jetzt zustimmen
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
}
export default function Layout() {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-8 overflow-auto">
<Outlet />
</main>
<div className="flex-1 flex flex-col overflow-auto">
<ConsentBanner />
<main className="flex-1 p-8">
<Outlet />
</main>
</div>
<ScrollToTopButton />
</div>
);
+21 -1
View File
@@ -1,5 +1,7 @@
import { NavLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../../context/AuthContext';
import { gdprApi } from '../../services/api';
import {
LayoutDashboard,
Users,
@@ -11,17 +13,35 @@ import {
ClipboardList,
MessageSquare,
AlertCircle,
Shield,
FileCheck,
UserCircle,
Gauge,
} from 'lucide-react';
export default function Sidebar() {
const { user, logout, hasPermission, isCustomer, developerMode } = useAuth();
const { user, logout, hasPermission, isCustomer, isCustomerPortal, developerMode } = useAuth();
// Prüfe ob Vollmachten vorhanden sind (nur für Portal-Kunden)
const { data: authData } = useQuery({
queryKey: ['my-authorizations'],
queryFn: () => gdprApi.getMyAuthorizations(),
enabled: isCustomerPortal,
staleTime: 60_000,
});
const hasAuthorizations = (authData?.data?.length ?? 0) > 0;
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', show: true, end: true },
{ to: '/my-profile', icon: UserCircle, label: 'Meine Daten', show: isCustomerPortal },
{ to: '/customers', icon: Users, label: 'Kunden', show: hasPermission('customers:read') && !isCustomer },
{ to: '/contracts', icon: FileText, label: 'Verträge', show: hasPermission('contracts:read'), end: true },
{ to: '/contracts/cockpit', icon: AlertCircle, label: 'Vertrags-Cockpit', show: hasPermission('contracts:read') && !isCustomer },
{ to: '/tasks', icon: isCustomer ? MessageSquare : ClipboardList, label: isCustomer ? 'Support-Anfragen' : 'Aufgaben', show: hasPermission('contracts:read') },
{ to: '/my-meters', icon: Gauge, label: 'Zählerstände', show: isCustomerPortal },
{ to: '/privacy', icon: Shield, label: 'Datenschutz', show: isCustomerPortal },
{ to: '/authorizations', icon: FileCheck, label: 'Vollmachten', show: isCustomerPortal && hasAuthorizations },
];
const developerItems = [
+18 -4
View File
@@ -1,4 +1,4 @@
import { ReactNode, useState } from 'react';
import { ReactNode, useState, useEffect } from 'react';
interface Tab {
id: string;
@@ -9,10 +9,24 @@ interface Tab {
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
activeTab?: string;
onTabChange?: (tabId: string) => void;
}
export default function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange }: TabsProps) {
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
const activeTab = controlledTab ?? internalTab;
useEffect(() => {
if (controlledTab !== undefined) {
setInternalTab(controlledTab);
}
}, [controlledTab]);
const handleTabChange = (tabId: string) => {
setInternalTab(tabId);
onTabChange?.(tabId);
};
return (
<div>
@@ -21,7 +35,7 @@ export default function Tabs({ tabs, defaultTab }: TabsProps) {
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
onClick={() => handleTabChange(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
+114 -1
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database } from 'lucide-react';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
@@ -160,6 +160,119 @@ export default function Settings() {
</div>
</div>
</Link>
{hasPermission('audit:read') && (
<Link
to="/settings/audit-logs"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<FileText className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Audit-Protokoll
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Protokollierte API-Zugriffe und Änderungen einsehen.</p>
{hasPermission('developer:access') && (
<p className="text-xs text-purple-500 mt-1 flex items-center gap-1">
<Code className="w-3 h-3" />
Sichtbar durch Entwicklerzugriff
</p>
)}
</div>
</div>
</Link>
)}
{hasPermission('gdpr:admin') && (
<Link
to="/settings/email-logs"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Mail className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
E-Mail-Versandlog
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Alle gesendeten E-Mails mit SMTP-Details und Status einsehen.</p>
</div>
</div>
</Link>
)}
{hasPermission('gdpr:admin') && (
<Link
to="/settings/gdpr"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Shield className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
DSGVO-Dashboard
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Löschanfragen, Datenexporte und Einwilligungen verwalten.</p>
{hasPermission('developer:access') && (
<p className="text-xs text-purple-500 mt-1 flex items-center gap-1">
<Code className="w-3 h-3" />
Sichtbar durch Entwicklerzugriff
</p>
)}
</div>
</div>
</Link>
)}
{hasPermission('gdpr:admin') && (
<Link
to="/settings/privacy-policy"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<FileEdit className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Datenschutzerklärung
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Datenschutzerklärung bearbeiten mit Platzhaltern für Kundendaten.</p>
{hasPermission('developer:access') && (
<p className="text-xs text-purple-500 mt-1 flex items-center gap-1">
<Code className="w-3 h-3" />
Sichtbar durch Entwicklerzugriff
</p>
)}
</div>
</div>
</Link>
)}
{hasPermission('gdpr:admin') && (
<Link
to="/settings/authorization-template"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<FileEdit className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Vollmacht-Vorlage
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Vollmacht-Vorlage für Vertreterregelungen bearbeiten.</p>
</div>
</div>
</Link>
)}
</div>
</div>
)}
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { contractApi } from '../../services/api';
import { contractApi, meterApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
import Select from '../../components/ui/Select';
@@ -28,6 +28,12 @@ import {
BellOff,
RotateCcw,
Receipt,
ShieldAlert,
ShieldX,
CreditCard,
Gauge,
ExternalLink,
CheckCircle2,
} from 'lucide-react';
import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types';
@@ -84,6 +90,11 @@ const issueTypeIcons: Record<string, typeof Calendar> = {
draft_status: FileText,
review_due: RotateCcw,
missing_invoice: Receipt,
missing_identity_document: CreditCard,
identity_document_expired: CreditCard,
identity_document_expiring: CreditCard,
missing_consents: ShieldAlert,
consent_withdrawn: ShieldX,
};
const categoryLabels: Record<string, string> = {
@@ -95,9 +106,10 @@ const categoryLabels: Record<string, string> = {
pendingContracts: 'Wartende Verträge',
missingInvoices: 'Fehlende Rechnungen',
reviewDue: 'Erneute Prüfung fällig',
missingConsents: 'Fehlende Einwilligungen',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices';
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents';
export default function ContractCockpit() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -212,6 +224,10 @@ export default function ContractCockpit() {
return contracts.filter(c =>
c.issues.some(i => i.type.includes('invoice'))
);
case 'consents':
return contracts.filter(c =>
c.issues.some(i => ['missing_consents', 'consent_withdrawn'].includes(i.type))
);
default:
return contracts;
}
@@ -510,6 +526,129 @@ export default function ContractCockpit() {
</div>
</Card>
{/* Ausweis-Warnungen (vertragsunabhängig) */}
{cockpitData.data.documentAlerts && cockpitData.data.documentAlerts.length > 0 && (
<Card className="mb-6">
<div className="flex items-center gap-2 mb-3">
<CreditCard className="w-5 h-5 text-amber-500" />
<h3 className="font-medium">Ablaufende Ausweise</h3>
<Badge variant="warning">{cockpitData.data.documentAlerts.length}</Badge>
</div>
<div className="space-y-2">
{cockpitData.data.documentAlerts.map((alert) => (
<div
key={alert.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
alert.urgency === 'critical' ? 'bg-red-50 border-red-200' : 'bg-amber-50 border-amber-200'
}`}
>
<div className="flex items-center gap-3">
<CreditCard className={`w-4 h-4 ${alert.urgency === 'critical' ? 'text-red-500' : 'text-amber-500'}`} />
<div>
<Link
to={`/customers/${alert.customer.id}?tab=documents`}
className="text-sm font-medium hover:underline"
>
{alert.customer.name}
</Link>
<span className="text-xs text-gray-500 ml-2">({alert.customer.customerNumber})</span>
<p className="text-xs text-gray-600">
{alert.type === 'ID_CARD' ? 'Personalausweis' :
alert.type === 'PASSPORT' ? 'Reisepass' :
alert.type === 'DRIVERS_LICENSE' ? 'Führerschein' : 'Ausweis'}{' '}
{alert.documentNumber}
</p>
</div>
</div>
<div className="text-right">
{alert.daysUntilExpiry < 0 ? (
<Badge variant="danger">Seit {Math.abs(alert.daysUntilExpiry)} Tagen abgelaufen</Badge>
) : (
<Badge variant={alert.daysUntilExpiry <= 30 ? 'danger' : 'warning'}>
Noch {alert.daysUntilExpiry} Tage
</Badge>
)}
<p className="text-xs text-gray-500 mt-1">
{new Date(alert.expiryDate).toLocaleDateString('de-DE')}
</p>
</div>
</div>
))}
</div>
</Card>
)}
{/* Gemeldete Zählerstände */}
{cockpitData.data.reportedReadings && cockpitData.data.reportedReadings.length > 0 && (
<Card className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Gauge className="w-5 h-5 text-blue-500" />
<h3 className="font-medium">Gemeldete Zählerstände</h3>
<Badge variant="warning">{cockpitData.data.reportedReadings.length}</Badge>
</div>
<p className="text-sm text-gray-500 mb-3">
Von Kunden gemeldete Zählerstände bitte an den jeweiligen Anbieter übertragen.
</p>
<div className="space-y-2">
{cockpitData.data.reportedReadings.map((reading) => (
<div
key={reading.id}
className="flex items-center justify-between p-3 rounded-lg border bg-blue-50 border-blue-200"
>
<div className="flex items-center gap-3">
{reading.meter.type === 'ELECTRICITY' ? (
<Zap className="w-4 h-4 text-yellow-500" />
) : (
<Flame className="w-4 h-4 text-blue-500" />
)}
<div>
<div className="flex items-center gap-2">
<Link
to={`/customers/${reading.customer.id}?tab=meters`}
className="text-sm font-medium hover:underline"
>
{reading.customer.name}
</Link>
<span className="text-xs text-gray-500">({reading.customer.customerNumber})</span>
</div>
<p className="text-xs text-gray-600">
Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '}
{new Date(reading.readingDate).toLocaleDateString('de-DE')}
{reading.notes && ` ${reading.notes}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{reading.providerPortal && (
<a
href={reading.providerPortal.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-white border rounded hover:bg-gray-50"
title={reading.providerPortal.portalUsername ? `Login: ${reading.providerPortal.portalUsername}` : undefined}
>
<ExternalLink className="w-3 h-3" />
{reading.providerPortal.providerName}
</a>
)}
<Button
variant="secondary"
size="sm"
onClick={async () => {
await meterApi.markTransferred(reading.meter.id, reading.id);
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
}}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Übertragen
</Button>
</div>
</div>
))}
</div>
</Card>
)}
{/* Filter */}
<Card className="mb-6">
<div className="flex items-center gap-4">
@@ -528,6 +667,7 @@ export default function ContractCockpit() {
{ value: 'tasks', label: `Aufgaben/Status (${summary.byCategory.openTasks + summary.byCategory.pendingContracts})` },
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
{ value: 'consents', label: `Einwilligungen (${summary.byCategory.missingConsents || 0})` },
]}
className="w-64"
/>
+45 -31
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
@@ -12,7 +12,7 @@ import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff } from 'lucide-react';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react';
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
@@ -1207,17 +1207,9 @@ function ContractTaskModal({
);
}
interface LocationState {
from?: 'customer' | 'contracts' | 'cockpit';
customerId?: string;
filter?: string; // Für Cockpit-Filter
}
export default function ContractDetail() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const locationState = location.state as LocationState | null;
const queryClient = useQueryClient();
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!);
@@ -1251,6 +1243,15 @@ export default function ContractDetail() {
queryFn: () => contractApi.getById(contractId),
});
// Consent-Check für den Kunden des Vertrags (nur für Mitarbeiter relevant)
const contractCustomerId = data?.data?.customerId;
const { data: consentStatusData } = useQuery({
queryKey: ['consent-status', contractCustomerId],
queryFn: () => gdprApi.checkConsentStatus(contractCustomerId!),
enabled: !!contractCustomerId && !isCustomerPortal,
});
const hasConsentApproval = isCustomerPortal || (consentStatusData?.data?.hasConsent ?? true);
const deleteMutation = useMutation({
mutationFn: () => contractApi.delete(contractId),
onSuccess: () => {
@@ -1422,6 +1423,38 @@ export default function ContractDetail() {
const c = data.data;
// Consent-Sperrung: Vertrag nicht anzeigen wenn Kunde keine Einwilligung hat
if (!hasConsentApproval) {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1>
</div>
<div className="flex flex-col items-center justify-center py-16 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>
<h2 className="text-lg font-semibold text-gray-800 mb-2">
Datenschutz-Einwilligung erforderlich
</h2>
<p className="text-sm text-gray-600 mb-6 max-w-md">
Die Vertragsdaten können nicht angezeigt werden, da der Kunde der Datenschutzerklärung noch nicht zugestimmt hat.
</p>
<Link
to={`/customers/${c.customerId}?tab=consents`}
className="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 Kunden: Einwilligungen / Datenschutz
</Link>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -1430,26 +1463,7 @@ export default function ContractDetail() {
<Button
variant="ghost"
size="sm"
onClick={() => {
// Zurück zur Herkunftsseite navigieren
if (locationState?.from === 'customer' && locationState?.customerId) {
// Kam von Kundendetail -> zurück zum Kunden mit Verträge-Tab
navigate(`/customers/${locationState.customerId}?tab=contracts`);
} else if (locationState?.from === 'cockpit') {
// Kam vom Cockpit -> zurück zum Cockpit (mit Filter falls vorhanden)
const filterParam = locationState.filter ? `?filter=${locationState.filter}` : '';
navigate(`/contracts/cockpit${filterParam}`);
} else if (locationState?.from === 'contracts') {
// Kam von Vertragsliste -> zurück zur Vertragsliste (URL-Parameter bleiben erhalten)
navigate('/contracts');
} else if (c.customer) {
// Fallback: Wenn Kunde vorhanden, zum Kunden
navigate(`/customers/${c.customer.id}?tab=contracts`);
} else {
// Fallback: Zur Vertragsliste
navigate('/contracts');
}
}}
onClick={() => navigate(-1)}
>
<ArrowLeft className="w-4 h-4" />
</Button>
@@ -8,7 +8,7 @@ import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { ContractType } from '../../types';
import { Plus, Trash2, Eye, EyeOff, Info, X } from 'lucide-react';
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
// Contract types are now loaded dynamically from the database
@@ -655,9 +655,14 @@ export default function ContractForm() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
</h1>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
</h1>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
+47 -4
View File
@@ -9,7 +9,8 @@ import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import Badge from '../../components/ui/Badge';
import CopyButton from '../../components/ui/CopyButton';
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight, Info, X } from 'lucide-react';
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight, Info, X, ShieldAlert } from 'lucide-react';
import { gdprApi } from '../../services/api';
import type { Contract, ContractType, ContractStatus } from '../../types';
const typeLabels: Record<ContractType, string> = {
@@ -137,6 +138,26 @@ export default function ContractList() {
return [...ids];
}, [data?.data, isCustomerPortal, user?.customerId]);
// Vollmacht-Status für vertretene Kunden (Portal)
const { data: authStatusData } = useQuery({
queryKey: ['my-authorization-status'],
queryFn: () => gdprApi.getMyAuthorizationStatus(),
enabled: isCustomerPortal,
});
const unauthorizedCustomers = useMemo(() => {
if (!isCustomerPortal || !authStatusData?.data || !user?.representedCustomers) return [];
return authStatusData.data
.filter((s) => !s.hasAuthorization)
.map((s) => {
const cust = user.representedCustomers?.find((c) => c.id === s.customerId);
return {
customerId: s.customerId,
customerName: cust ? `${cust.firstName} ${cust.lastName}` : `Kunde ${s.customerId}`,
};
});
}, [authStatusData?.data, isCustomerPortal, user?.representedCustomers]);
// Baumstruktur für alle Kunden laden (Kundenportal)
const treeQueries = useQueries({
queries: allCustomerIds.map(customerId => ({
@@ -250,10 +271,10 @@ export default function ContractList() {
<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 hover:text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</span>
</Link>
<Badge>{typeLabels[contract.type as ContractType] || contract.type}</Badge>
<Badge variant={statusVariants[contract.status as ContractStatus] || 'default'}>
{statusLabels[contract.status as ContractStatus] || contract.status}
@@ -383,6 +404,24 @@ export default function ContractList() {
)}
</Card>
))}
{/* Kunden ohne Vollmacht */}
{unauthorizedCustomers.map((uc) => (
<Card key={`no-auth-${uc.customerId}`}>
<div className="flex items-center gap-3 mb-2 pb-3 border-b">
<ShieldAlert className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-semibold text-gray-900">
Verträge von {uc.customerName}
</h2>
</div>
<div className="flex items-center gap-3 py-6 text-center justify-center">
<ShieldAlert className="w-5 h-5 text-amber-500" />
<p className="text-sm text-gray-600">
Einwilligung / Vollmacht fehlt. {uc.customerName} muss Ihnen zuerst eine Vollmacht erteilen.
</p>
</div>
</Card>
))}
</div>
) : (
/* Standard-Ansicht für Mitarbeiter */
@@ -416,7 +455,11 @@ export default function ContractList() {
<tbody>
{data.data.map((contract) => (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</td>
<td className="py-3 px-4 font-mono text-sm">
<Link to={`/contracts/${contract.id}`} className="text-blue-600 hover:underline">
{contract.contractNumber}
</Link>
</td>
{!isCustomer && (
<td className="py-3 px-4">
{contract.customer && (
+637 -126
View File
@@ -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>
);
}
@@ -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} />;
}
+235
View File
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { publicApi } from '../../services/api';
import { Shield, CheckCircle2, FileDown, Loader2 } from 'lucide-react';
export default function ConsentPage() {
const { hash } = useParams<{ hash: string }>();
const queryClient = useQueryClient();
const [allChecked, setAllChecked] = useState(false);
const [checks, setChecks] = useState<Record<string, boolean>>({});
const { data, isLoading, error } = useQuery({
queryKey: ['public-consent', hash],
queryFn: () => publicApi.getConsentPage(hash!),
enabled: !!hash,
});
const grantMutation = useMutation({
mutationFn: () => publicApi.grantAllConsents(hash!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['public-consent', hash] });
},
});
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex items-center gap-3 text-gray-500">
<Loader2 className="w-5 h-5 animate-spin" />
Laden...
</div>
</div>
);
}
if (error || !data?.data) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-sm border max-w-md text-center">
<Shield className="w-12 h-12 text-red-400 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 mb-2">Ungültiger Link</h1>
<p className="text-gray-500">
Dieser Datenschutz-Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Berater.
</p>
</div>
</div>
);
}
const { customer, consents, privacyPolicyHtml } = data.data;
const allGranted = consents.every((c) => c.status === 'GRANTED');
const consentTypes = consents.map((c) => c.consentType);
const allBoxesChecked = consentTypes.every((t) => checks[t]);
const handleToggle = (type: string) => {
setChecks((prev) => ({ ...prev, [type]: !prev[type] }));
};
const handleCheckAll = () => {
const newValue = !allChecked;
setAllChecked(newValue);
const newChecks: Record<string, boolean> = {};
consentTypes.forEach((t) => { newChecks[t] = newValue; });
setChecks(newChecks);
};
const handleSubmit = () => {
if (allBoxesChecked) {
grantMutation.mutate();
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-3xl mx-auto px-4 py-6">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-xl font-bold text-gray-900">Datenschutzerklärung</h1>
<p className="text-sm text-gray-500">
{customer.firstName} {customer.lastName} (Nr. {customer.customerNumber})
</p>
</div>
</div>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Bereits zugestimmt */}
{allGranted ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-600 flex-shrink-0" />
<div>
<h2 className="text-lg font-semibold text-green-800 mb-1">
Einwilligungen bereits erteilt
</h2>
<p className="text-green-700 text-sm mb-3">
Sie haben allen Einwilligungen zugestimmt. Vielen Dank!
</p>
<div className="space-y-1">
{consents.map((c) => (
<div key={c.consentType} className="flex items-center gap-2 text-sm text-green-700">
<CheckCircle2 className="w-4 h-4" />
<span>{c.label}</span>
{c.grantedAt && (
<span className="text-green-500">
(am {new Date(c.grantedAt).toLocaleDateString('de-DE')})
</span>
)}
</div>
))}
</div>
</div>
</div>
</div>
) : grantMutation.isSuccess ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-600 flex-shrink-0" />
<div>
<h2 className="text-lg font-semibold text-green-800 mb-1">
Vielen Dank!
</h2>
<p className="text-green-700 text-sm">
Ihre Einwilligungen wurden erfolgreich gespeichert.
</p>
</div>
</div>
</div>
) : null}
{/* Datenschutzerklärung */}
<div className="bg-white border rounded-lg shadow-sm mb-6">
<div className="p-6 border-b flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Datenschutzerklärung</h2>
<a
href={publicApi.getConsentPdfUrl(hash!)}
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="p-6 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
/>
</div>
{/* Einwilligungen (nur wenn noch nicht erteilt) */}
{!allGranted && !grantMutation.isSuccess && (
<div className="bg-white border rounded-lg shadow-sm">
<div className="p-6 border-b">
<h2 className="font-semibold text-gray-900 mb-1">Einwilligungen</h2>
<p className="text-sm text-gray-500">
Bitte stimmen Sie allen Punkten zu, damit wir Sie beraten können.
</p>
</div>
<div className="p-6 space-y-4">
{/* Alle auswählen */}
<label className="flex items-start gap-3 p-3 rounded-lg bg-blue-50 border border-blue-200 cursor-pointer hover:bg-blue-100 transition-colors">
<input
type="checkbox"
checked={allChecked && allBoxesChecked}
onChange={handleCheckAll}
className="mt-0.5 rounded border-blue-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="font-medium text-blue-900">Allen zustimmen</span>
</div>
</label>
<div className="border-t pt-4 space-y-3">
{consents.map((c) => (
<label
key={c.consentType}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
checks[c.consentType]
? 'bg-green-50 border-green-200'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={checks[c.consentType] || false}
onChange={() => handleToggle(c.consentType)}
className="mt-0.5 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div>
<span className="font-medium text-gray-900">{c.label} *</span>
<p className="text-sm text-gray-500 mt-0.5">{c.description}</p>
</div>
</label>
))}
</div>
<p className="text-xs text-gray-400">* Pflichtfeld</p>
{grantMutation.isError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
Fehler beim Speichern. Bitte versuchen Sie es erneut.
</div>
)}
<button
onClick={handleSubmit}
disabled={!allBoxesChecked || grantMutation.isPending}
className={`w-full py-3 px-4 rounded-lg font-medium text-white transition-colors ${
allBoxesChecked && !grantMutation.isPending
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-300 cursor-not-allowed'
}`}
>
{grantMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Wird gespeichert...
</span>
) : (
'Zustimmen'
)}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+413
View File
@@ -0,0 +1,413 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { auditLogApi, AuditLogSearchParams } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import { ArrowLeft, Download, Eye, Shield, ShieldCheck, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
const ACTION_OPTIONS = [
{ value: '', label: 'Alle Aktionen' },
{ value: 'CREATE', label: 'Erstellt' },
{ value: 'READ', label: 'Gelesen' },
{ value: 'UPDATE', label: 'Aktualisiert' },
{ value: 'DELETE', label: 'Gelöscht' },
{ value: 'EXPORT', label: 'Exportiert' },
{ value: 'ANONYMIZE', label: 'Anonymisiert' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
];
const SENSITIVITY_OPTIONS = [
{ value: '', label: 'Alle Stufen' },
{ value: 'LOW', label: 'Niedrig' },
{ value: 'MEDIUM', label: 'Mittel' },
{ value: 'HIGH', label: 'Hoch' },
{ value: 'CRITICAL', label: 'Kritisch' },
];
const RESOURCE_OPTIONS = [
{ value: '', label: 'Alle Ressourcen' },
{ value: 'Customer', label: 'Kunden' },
{ value: 'Contract', label: 'Verträge' },
{ value: 'User', label: 'Benutzer' },
{ value: 'BankCard', label: 'Bankdaten' },
{ value: 'IdentityDocument', label: 'Ausweisdokumente' },
{ value: 'Authentication', label: 'Authentifizierung' },
{ value: 'CustomerConsent', label: 'Einwilligungen' },
{ value: 'GDPR', label: 'DSGVO' },
];
function formatDate(date: string): string {
return new Date(date).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getActionColor(action: AuditAction): string {
switch (action) {
case 'CREATE': return 'bg-green-100 text-green-800';
case 'READ': return 'bg-blue-100 text-blue-800';
case 'UPDATE': return 'bg-yellow-100 text-yellow-800';
case 'DELETE': return 'bg-red-100 text-red-800';
case 'EXPORT': return 'bg-purple-100 text-purple-800';
case 'ANONYMIZE': return 'bg-orange-100 text-orange-800';
case 'LOGIN': return 'bg-teal-100 text-teal-800';
case 'LOGOUT': return 'bg-gray-100 text-gray-800';
case 'LOGIN_FAILED': return 'bg-red-200 text-red-900';
default: return 'bg-gray-100 text-gray-800';
}
}
function getSensitivityIcon(sensitivity: AuditSensitivity) {
switch (sensitivity) {
case 'LOW': return <Shield className="w-4 h-4 text-gray-400" />;
case 'MEDIUM': return <Shield className="w-4 h-4 text-blue-500" />;
case 'HIGH': return <ShieldAlert className="w-4 h-4 text-orange-500" />;
case 'CRITICAL': return <ShieldAlert className="w-4 h-4 text-red-500" />;
default: return <Shield className="w-4 h-4 text-gray-400" />;
}
}
interface DetailModalProps {
log: AuditLog;
onClose: () => void;
}
function DetailModal({ log, onClose }: DetailModalProps) {
const parseChanges = (changes: string | undefined): Record<string, unknown> | null => {
if (!changes) return null;
try {
return JSON.parse(changes);
} catch {
return null;
}
};
const before = parseChanges(log.changesBefore);
const after = parseChanges(log.changesAfter);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Audit-Log Details</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<dt className="text-sm text-gray-500">Zeitpunkt</dt>
<dd className="font-medium">{formatDate(log.createdAt)}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Benutzer</dt>
<dd className="font-medium">{log.userEmail}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Aktion</dt>
<dd><span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>{log.action}</span></dd>
</div>
<div>
<dt className="text-sm text-gray-500">Ressource</dt>
<dd className="font-medium">{log.resourceType} {log.resourceId && `#${log.resourceId}`}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Endpoint</dt>
<dd className="font-mono text-sm">{log.httpMethod} {log.endpoint}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">IP-Adresse</dt>
<dd className="font-mono text-sm">{log.ipAddress}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Sensitivität</dt>
<dd className="flex items-center gap-2">{getSensitivityIcon(log.sensitivity)} {log.sensitivity}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Dauer</dt>
<dd>{log.durationMs ? `${log.durationMs}ms` : '-'}</dd>
</div>
{log.resourceLabel && (
<div className="col-span-2">
<dt className="text-sm text-gray-500">Ressource-Bezeichnung</dt>
<dd className="font-medium">{log.resourceLabel}</dd>
</div>
)}
</div>
{log.changesEncrypted && (
<div className="mb-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg text-sm">
Die Änderungsdaten sind verschlüsselt und können nur mit dem Encryption-Key eingesehen werden.
</div>
)}
{(before || after) && !log.changesEncrypted && (
<div className="border-t pt-4">
<h3 className="font-medium mb-3">Änderungen</h3>
<div className="grid grid-cols-2 gap-4">
{before && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Vorher</h4>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(before, null, 2)}
</pre>
</div>
)}
{after && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Nachher</h4>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(after, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
{log.hash && (
<div className="border-t pt-4 mt-4">
<h3 className="font-medium mb-2">Integrität</h3>
<div className="text-xs font-mono bg-gray-50 p-2 rounded break-all">
<div><span className="text-gray-500">Hash:</span> {log.hash}</div>
{log.previousHash && <div className="mt-1"><span className="text-gray-500">Vorheriger:</span> {log.previousHash}</div>}
</div>
</div>
)}
<div className="flex justify-end mt-6 pt-4 border-t">
<Button variant="secondary" onClick={onClose}>Schließen</Button>
</div>
</div>
</div>
</div>
);
}
export default function AuditLogs() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<AuditLogSearchParams>({
page: 1,
limit: 50,
});
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const { data: logsData, isLoading, refetch } = useQuery({
queryKey: ['audit-logs', { ...filters, page }],
queryFn: () => auditLogApi.search({ ...filters, page }),
});
const verifyMutation = useMutation({
mutationFn: () => auditLogApi.verifyIntegrity(),
onSuccess: (result) => {
if (result.data?.valid) {
alert('Hash-Kette ist intakt. Keine Manipulationen festgestellt.');
} else {
alert(`Integritätsfehler gefunden:\n${result.data?.errors?.join('\n')}`);
}
},
onError: (error) => {
alert(`Fehler bei der Integritätsprüfung: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
},
});
const logs = logsData?.data || [];
const pagination = logsData?.pagination;
const handleFilterChange = (key: keyof AuditLogSearchParams, value: string) => {
setFilters(prev => ({ ...prev, [key]: value || undefined }));
setPage(1);
};
const handleExport = async (format: 'json' | 'csv') => {
try {
const result = await auditLogApi.export({ ...filters, format });
const data = result.data;
const blob = new Blob(
[format === 'json' ? JSON.stringify(data, null, 2) : ''],
{ type: format === 'json' ? 'application/json' : 'text/csv' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
alert(`Export fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
}
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
</div>
{/* Filter */}
<Card className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Input
placeholder="Suche..."
value={filters.search || ''}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full"
/>
<Select
value={filters.action || ''}
onChange={(e) => handleFilterChange('action', e.target.value)}
options={ACTION_OPTIONS}
/>
<Select
value={filters.resourceType || ''}
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
options={RESOURCE_OPTIONS}
/>
<Select
value={filters.sensitivity || ''}
onChange={(e) => handleFilterChange('sensitivity', e.target.value as AuditSensitivity)}
options={SENSITIVITY_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Input
type="date"
label="Von"
value={filters.startDate || ''}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
/>
<Input
type="date"
label="Bis"
value={filters.endDate || ''}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
/>
<div></div>
<div className="flex gap-2 justify-end items-end">
<Button variant="secondary" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-2" />
Aktualisieren
</Button>
<Button variant="secondary" onClick={() => handleExport('json')}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button variant="secondary" onClick={() => verifyMutation.mutate()} disabled={verifyMutation.isPending}>
<ShieldCheck className="w-4 h-4 mr-2" />
{verifyMutation.isPending ? 'Prüfe...' : 'Integrität'}
</Button>
</div>
</div>
</Card>
{/* Tabelle */}
<Card>
{isLoading ? (
<div className="text-center py-8">Laden...</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-gray-500">Keine Audit-Logs gefunden.</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Zeitpunkt</th>
<th className="text-left py-3 px-4">Benutzer</th>
<th className="text-left py-3 px-4">Aktion</th>
<th className="text-left py-3 px-4">Ressource</th>
<th className="text-left py-3 px-4">IP</th>
<th className="text-center py-3 px-4"></th>
<th className="text-center py-3 px-4"></th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 whitespace-nowrap">{formatDate(log.createdAt)}</td>
<td className="py-3 px-4">
<div className="truncate max-w-[200px]" title={log.userEmail}>
{log.userEmail}
</div>
{log.userRole && <div className="text-xs text-gray-500">{log.userRole}</div>}
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>
{log.action}
</span>
</td>
<td className="py-3 px-4">
<div>{log.resourceType}</div>
{log.resourceLabel && (
<div className="text-xs text-gray-500 truncate max-w-[200px]" title={log.resourceLabel}>
{log.resourceLabel}
</div>
)}
</td>
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>
<td className="py-3 px-4 text-center">{getSensitivityIcon(log.sensitivity)}</td>
<td className="py-3 px-4 text-center">
<Button variant="ghost" size="sm" onClick={() => setSelectedLog(log)}>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))}
disabled={page === pagination.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>
{/* Detail Modal */}
{selectedLog && (
<DetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
)}
</div>
);
}
@@ -0,0 +1,262 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import TiptapLink from '@tiptap/extension-link';
import { gdprApi } from '../../services/api';
import Button from '../../components/ui/Button';
import Card from '../../components/ui/Card';
import { ArrowLeft, Save, Eye, Bold, Italic, List, ListOrdered, Heading1, Heading2, Heading3, Link as LinkIcon, Undo, Redo, Type } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
const DEFAULT_TEMPLATE = `<h1>Vollmacht</h1>
<p>Hiermit bevollmächtige ich,</p>
<p><strong>{{vollmachtgeber_vorname}} {{vollmachtgeber_nachname}}</strong><br>
Kundennummer: {{vollmachtgeber_kundennummer}}</p>
<p>den/die</p>
<p><strong>{{bevollmaechtigter_vorname}} {{bevollmaechtigter_nachname}}</strong><br>
Kundennummer: {{bevollmaechtigter_kundennummer}}</p>
<p>mich in allen Angelegenheiten rund um meine Telekommunikationsverträge bei der Firma Hacker-Net Telekommunikation Stefan Hacker zu vertreten. Dies umfasst insbesondere:</p>
<ul>
<li>Einsicht in meine Vertragsdaten, Rechnungen und Kundendaten</li>
<li>Kommunikation mit dem Kundenservice in meinem Namen</li>
<li>Entgegennahme von Informationen zu meinen Verträgen</li>
</ul>
<p>Diese Vollmacht gilt bis auf Widerruf. Ich kann sie jederzeit schriftlich oder über das Kundenportal widerrufen.</p>
<h2>Datenschutzhinweis</h2>
<p>Mit der Erteilung dieser Vollmacht erkläre ich mich damit einverstanden, dass die oben genannte bevollmächtigte Person Zugriff auf meine bei Hacker-Net Telekommunikation gespeicherten personenbezogenen Daten erhält. Dies geschieht auf Grundlage meiner ausdrücklichen Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO.</p>
<p>Ich bin darüber informiert, dass ich diese Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen kann.</p>
<p>&nbsp;</p>
<p>Oldenburg, den {{datum}}</p>
<p>&nbsp;</p>
<p>_______________________________<br>
Unterschrift des Vollmachtgebers</p>
<p style="color: #9ca3af; font-size: 12px; margin-top: 32px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>`;
const PLACEHOLDERS = [
{ key: '{{vollmachtgeber_vorname}}', label: 'Vorname (Vollmachtgeber)' },
{ key: '{{vollmachtgeber_nachname}}', label: 'Nachname (Vollmachtgeber)' },
{ key: '{{vollmachtgeber_kundennummer}}', label: 'Kundennr. (Vollmachtgeber)' },
{ key: '{{bevollmaechtigter_vorname}}', label: 'Vorname (Bevollmächtigter)' },
{ key: '{{bevollmaechtigter_nachname}}', label: 'Nachname (Bevollmächtigter)' },
{ key: '{{bevollmaechtigter_kundennummer}}', label: 'Kundennr. (Bevollmächtigter)' },
{ key: '{{datum}}', label: 'Aktuelles Datum' },
];
function MenuBar({ editor }: { editor: ReturnType<typeof useEditor> }) {
if (!editor) return null;
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL eingeben:', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
const btnClass = (active: boolean) =>
`p-1.5 rounded hover:bg-gray-200 transition-colors ${active ? 'bg-gray-200 text-blue-600' : 'text-gray-600'}`;
return (
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-gray-50">
<button type="button" onClick={() => editor.chain().focus().toggleBold().run()} className={btnClass(editor.isActive('bold'))} title="Fett">
<Bold className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleItalic().run()} className={btnClass(editor.isActive('italic'))} title="Kursiv">
<Italic className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={btnClass(editor.isActive('heading', { level: 1 }))} title="Überschrift 1">
<Heading1 className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={btnClass(editor.isActive('heading', { level: 2 }))} title="Überschrift 2">
<Heading2 className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={btnClass(editor.isActive('heading', { level: 3 }))} title="Überschrift 3">
<Heading3 className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().setParagraph().run()} className={btnClass(editor.isActive('paragraph'))} title="Absatz">
<Type className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={() => editor.chain().focus().toggleBulletList().run()} className={btnClass(editor.isActive('bulletList'))} title="Aufzählung">
<List className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={btnClass(editor.isActive('orderedList'))} title="Nummerierung">
<ListOrdered className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={setLink} className={btnClass(editor.isActive('link'))} title="Link">
<LinkIcon className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Rückgängig">
<Undo className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Wiederherstellen">
<Redo className="w-4 h-4" />
</button>
</div>
);
}
export default function AuthorizationTemplateEditor() {
const queryClient = useQueryClient();
const [showPreview, setShowPreview] = useState(false);
const [saved, setSaved] = useState(false);
const { data: templateData, isLoading } = useQuery({
queryKey: ['authorization-template'],
queryFn: () => gdprApi.getAuthorizationTemplate(),
});
const saveMutation = useMutation({
mutationFn: (html: string) => gdprApi.updateAuthorizationTemplate(html),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['authorization-template'] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
// Wenn noch keine Vorlage gespeichert: Default-Template verwenden
const initialContent = templateData?.data?.html || DEFAULT_TEMPLATE;
const editor = useEditor({
extensions: [
StarterKit,
TiptapLink.configure({
openOnClick: false,
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
}),
],
content: initialContent,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none p-4 min-h-[400px] focus:outline-none',
},
},
}, [initialContent]);
const insertPlaceholder = (key: string) => {
if (editor) {
editor.chain().focus().insertContent(key).run();
}
};
const handleSave = () => {
if (editor) {
saveMutation.mutate(editor.getHTML());
}
};
const handleResetToDefault = () => {
if (confirm('Vorlage auf den Standardtext zurücksetzen? Alle Änderungen gehen verloren.')) {
editor?.commands.setContent(DEFAULT_TEMPLATE);
}
};
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Laden...</div>;
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<RouterLink to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</RouterLink>
<h1 className="text-2xl font-bold flex-1">Vollmacht-Vorlage bearbeiten</h1>
<Button
variant="ghost"
size="sm"
onClick={handleResetToDefault}
>
Standardtext laden
</Button>
<Button
variant="secondary"
onClick={() => setShowPreview(!showPreview)}
>
<Eye className="w-4 h-4 mr-2" />
{showPreview ? 'Editor' : 'Vorschau'}
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
{saved ? 'Gespeichert!' : saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Editor / Vorschau */}
<div className="lg:col-span-3">
{showPreview ? (
<Card>
<div
className="prose prose-sm max-w-none p-4"
dangerouslySetInnerHTML={{ __html: editor?.getHTML() || '' }}
/>
</Card>
) : (
<div className="border rounded-lg bg-white overflow-hidden">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
)}
</div>
{/* Platzhalter-Sidebar */}
<div className="lg:col-span-1">
<Card title="Platzhalter">
<p className="text-xs text-gray-500 mb-3">
Klicken Sie auf einen Platzhalter, um ihn an der Cursorposition einzufügen.
</p>
<div className="space-y-2">
{PLACEHOLDERS.map((p) => (
<button
key={p.key}
onClick={() => insertPlaceholder(p.key)}
className="w-full text-left px-3 py-2 text-sm bg-gray-50 border rounded hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<span className="font-mono text-blue-600 text-xs">{p.key}</span>
<br />
<span className="text-gray-600">{p.label}</span>
</button>
))}
</div>
</Card>
{saveMutation.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>
</div>
</div>
);
}
@@ -5,7 +5,7 @@ import { appSettingsApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Input from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle } from 'lucide-react';
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle, CreditCard } from 'lucide-react';
export default function DeadlineSettings() {
const queryClient = useQueryClient();
@@ -18,6 +18,8 @@ export default function DeadlineSettings() {
const [criticalDays, setCriticalDays] = useState('14');
const [warningDays, setWarningDays] = useState('42');
const [okDays, setOkDays] = useState('90');
const [docCriticalDays, setDocCriticalDays] = useState('30');
const [docWarningDays, setDocWarningDays] = useState('90');
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
@@ -25,6 +27,8 @@ export default function DeadlineSettings() {
setCriticalDays(settingsData.data.deadlineCriticalDays || '14');
setWarningDays(settingsData.data.deadlineWarningDays || '42');
setOkDays(settingsData.data.deadlineOkDays || '90');
setDocCriticalDays(settingsData.data.documentExpiryCriticalDays || '30');
setDocWarningDays(settingsData.data.documentExpiryWarningDays || '90');
setHasChanges(false);
}
}, [settingsData]);
@@ -39,7 +43,7 @@ export default function DeadlineSettings() {
});
const handleSave = () => {
// Validierung
// Validierung Vertragsfristen
const critical = parseInt(criticalDays);
const warning = parseInt(warningDays);
const ok = parseInt(okDays);
@@ -54,10 +58,26 @@ export default function DeadlineSettings() {
return;
}
// Validierung Ausweis-Fristen
const docCrit = parseInt(docCriticalDays);
const docWarn = parseInt(docWarningDays);
if (isNaN(docCrit) || isNaN(docWarn)) {
alert('Bitte gültige Zahlen für Ausweis-Fristen eingeben');
return;
}
if (docCrit >= docWarn) {
alert('Ausweis-Fristen: Kritisch muss kleiner als Warnung sein');
return;
}
updateMutation.mutate({
deadlineCriticalDays: criticalDays,
deadlineWarningDays: warningDays,
deadlineOkDays: okDays,
documentExpiryCriticalDays: docCriticalDays,
documentExpiryWarningDays: docWarningDays,
});
};
@@ -163,19 +183,79 @@ export default function DeadlineSettings() {
</div>
</div>
<div className="mt-6 pt-4 border-t flex justify-between items-center">
<div className="mt-6 pt-4 border-t">
<p className="text-sm text-gray-500">
Beispiel: Bei 14/42/90 Tagen wird eine Frist die in 10 Tagen abläuft rot,
eine in 30 Tagen gelb, und eine in 60 Tagen grün markiert.
</p>
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
>
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
</Button>
</div>
</Card>
<Card title="Ausweis-Ablauffristen" className="mt-6">
<div className="flex items-center gap-2 mb-4">
<CreditCard className="w-5 h-5 text-gray-500" />
<p className="text-gray-600">
Ab wann ablaufende Ausweise im Cockpit als Warnung oder kritisch angezeigt werden.
</p>
</div>
<div className="space-y-6">
{/* Kritisch (Rot) */}
<div className="flex items-center gap-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-8 h-8 text-red-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-red-800 mb-1">
Kritisch (Rot)
</label>
<p className="text-sm text-red-600 mb-2">
Ausweise die in weniger als X Tagen ablaufen werden rot markiert
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={docCriticalDays}
onChange={(e) => handleChange(setDocCriticalDays, e.target.value)}
className="w-24"
/>
<span className="text-red-700">Tage</span>
</div>
</div>
</div>
{/* Warnung (Gelb) */}
<div className="flex items-center gap-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<AlertTriangle className="w-8 h-8 text-yellow-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-yellow-800 mb-1">
Warnung (Gelb)
</label>
<p className="text-sm text-yellow-600 mb-2">
Ausweise die in weniger als X Tagen ablaufen werden gelb markiert
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={docWarningDays}
onChange={(e) => handleChange(setDocWarningDays, e.target.value)}
className="w-24"
/>
<span className="text-yellow-700">Tage</span>
</div>
</div>
</div>
</div>
</Card>
<div className="mt-6 flex justify-end">
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
>
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
</Button>
</div>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { emailLogApi, EmailLog } from '../../services/api';
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 Modal from '../../components/ui/Modal';
import { ArrowLeft, CheckCircle2, XCircle, Mail, Server, ChevronLeft, ChevronRight } from 'lucide-react';
const CONTEXT_LABELS: Record<string, string> = {
'consent-link': 'Datenschutz-Link',
'authorization-request': 'Vollmacht-Anfrage',
'customer-email': 'Kunden-E-Mail',
};
export default function EmailLogs() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [contextFilter, setContextFilter] = useState<string>('');
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
const { data: statsData } = useQuery({
queryKey: ['email-log-stats'],
queryFn: () => emailLogApi.getStats(),
});
const { data: logsData, isLoading } = useQuery({
queryKey: ['email-logs', page, search, statusFilter, contextFilter],
queryFn: () => emailLogApi.getLogs({
page,
limit: 30,
success: statusFilter || undefined,
search: search || undefined,
context: contextFilter || undefined,
}),
});
const stats = statsData?.data;
const logs = logsData?.data || [];
const pagination = logsData?.pagination;
const formatDate = (date: string) =>
new Date(date).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<h1 className="text-2xl font-bold">E-Mail-Versandlog</h1>
</div>
{/* Statistiken */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
<p className="text-sm text-gray-500">Gesamt</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{stats.success}</p>
<p className="text-sm text-gray-500">Erfolgreich</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{stats.failed}</p>
<p className="text-sm text-gray-500">Fehlgeschlagen</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{stats.last24h}</p>
<p className="text-sm text-gray-500">Letzte 24h</p>
</div>
</Card>
</div>
)}
{/* Filter */}
<Card className="mb-6">
<div className="flex gap-4 flex-wrap">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Suche (Absender, Empfänger, Betreff...)"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Alle Status</option>
<option value="true">Erfolgreich</option>
<option value="false">Fehlgeschlagen</option>
</select>
<select
value={contextFilter}
onChange={(e) => { setContextFilter(e.target.value); setPage(1); }}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Alle Typen</option>
<option value="consent-link">Datenschutz-Link</option>
<option value="authorization-request">Vollmacht-Anfrage</option>
<option value="customer-email">Kunden-E-Mail</option>
</select>
</div>
</Card>
{/* Log-Tabelle */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-gray-500">Keine E-Mail-Logs vorhanden.</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Status</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Zeitpunkt</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Typ</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Von</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">An</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Betreff</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">SMTP</th>
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm"></th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3">
{log.success ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
</td>
<td className="py-2 px-3 text-xs text-gray-500 whitespace-nowrap">
{formatDate(log.sentAt)}
</td>
<td className="py-2 px-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
{CONTEXT_LABELS[log.context] || log.context}
</span>
</td>
<td className="py-2 px-3 text-sm truncate max-w-[150px]">{log.fromAddress}</td>
<td className="py-2 px-3 text-sm truncate max-w-[150px]">{log.toAddress}</td>
<td className="py-2 px-3 text-sm truncate max-w-[200px]">{log.subject}</td>
<td className="py-2 px-3 text-xs text-gray-500 whitespace-nowrap">
{log.smtpServer}:{log.smtpPort}
</td>
<td className="py-2 px-3">
<button
onClick={() => setSelectedLog(log)}
className="text-blue-600 hover:underline text-xs"
>
Details
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
disabled={page >= pagination.totalPages}
onClick={() => setPage(page + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>
{/* Detail-Modal */}
{selectedLog && (
<Modal
isOpen={true}
onClose={() => setSelectedLog(null)}
title="E-Mail-Log Details"
>
<div className="space-y-4">
{/* Status */}
<div className="flex items-center gap-2">
{selectedLog.success ? (
<Badge variant="success">Erfolgreich</Badge>
) : (
<Badge variant="danger">Fehlgeschlagen</Badge>
)}
<span className="text-sm text-gray-500">{formatDate(selectedLog.sentAt)}</span>
</div>
{/* E-Mail-Details */}
<div className="border rounded-lg p-4 space-y-2">
<div className="flex items-start gap-2">
<Mail className="w-4 h-4 text-gray-400 mt-0.5" />
<div className="flex-1">
<div className="grid grid-cols-[80px_1fr] gap-1 text-sm">
<span className="text-gray-500">Von:</span>
<span>{selectedLog.fromAddress}</span>
<span className="text-gray-500">An:</span>
<span>{selectedLog.toAddress}</span>
<span className="text-gray-500">Betreff:</span>
<span>{selectedLog.subject}</span>
<span className="text-gray-500">Typ:</span>
<span>{CONTEXT_LABELS[selectedLog.context] || selectedLog.context}</span>
{selectedLog.triggeredBy && (
<>
<span className="text-gray-500">Ausgelöst von:</span>
<span>{selectedLog.triggeredBy}</span>
</>
)}
{selectedLog.messageId && (
<>
<span className="text-gray-500">Message-ID:</span>
<span className="font-mono text-xs break-all">{selectedLog.messageId}</span>
</>
)}
</div>
</div>
</div>
</div>
{/* SMTP-Details */}
<div className="border rounded-lg p-4 space-y-2">
<div className="flex items-start gap-2">
<Server className="w-4 h-4 text-gray-400 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium mb-2">SMTP-Verbindung</h4>
<div className="grid grid-cols-[100px_1fr] gap-1 text-sm">
<span className="text-gray-500">Server:</span>
<span className="font-mono text-xs">{selectedLog.smtpServer}:{selectedLog.smtpPort}</span>
<span className="text-gray-500">Verschlüsselung:</span>
<span>{selectedLog.smtpEncryption}</span>
<span className="text-gray-500">Benutzer:</span>
<span className="font-mono text-xs">{selectedLog.smtpUser}</span>
</div>
</div>
</div>
</div>
{/* SMTP-Antwort */}
{selectedLog.smtpResponse && (
<div className="border rounded-lg p-4">
<h4 className="text-sm font-medium mb-2">SMTP-Antwort</h4>
<pre className="text-xs bg-gray-50 p-3 rounded overflow-x-auto whitespace-pre-wrap font-mono text-gray-700">
{selectedLog.smtpResponse}
</pre>
</div>
)}
{/* Fehlermeldung */}
{selectedLog.errorMessage && (
<div className="border border-red-200 rounded-lg p-4 bg-red-50">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehlermeldung</h4>
<pre className="text-xs whitespace-pre-wrap font-mono text-red-700">
{selectedLog.errorMessage}
</pre>
</div>
)}
</div>
</Modal>
)}
</div>
);
}
+59 -1
View File
@@ -6,7 +6,7 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff, Mail } from 'lucide-react';
const PROVIDER_TYPES = [
{ value: 'PLESK', label: 'Plesk' },
@@ -36,6 +36,9 @@ interface ProviderFormData {
imapEncryption: MailEncryption;
smtpEncryption: MailEncryption;
allowSelfSignedCerts: boolean;
// System-E-Mail
systemEmailAddress: string;
systemEmailPassword: string;
isActive: boolean;
isDefault: boolean;
}
@@ -52,6 +55,8 @@ const emptyForm: ProviderFormData = {
imapEncryption: 'SSL',
smtpEncryption: 'SSL',
allowSelfSignedCerts: false,
systemEmailAddress: '',
systemEmailPassword: '',
isActive: true,
isDefault: false,
};
@@ -69,6 +74,7 @@ export default function EmailProviders() {
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<ProviderFormData>(emptyForm);
const [showPassword, setShowPassword] = useState(false);
const [showSystemPassword, setShowSystemPassword] = useState(false);
const [modalTestResult, setModalTestResult] = useState<TestResult | null>(null);
const [isTestingInModal, setIsTestingInModal] = useState(false);
// Test-Status pro Provider in der Liste
@@ -128,6 +134,8 @@ export default function EmailProviders() {
imapEncryption: config.imapEncryption ?? 'SSL',
smtpEncryption: config.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
systemEmailAddress: config.systemEmailAddress || '',
systemEmailPassword: '', // Passwort wird nicht geladen
isActive: config.isActive,
isDefault: config.isDefault,
});
@@ -222,6 +230,7 @@ export default function EmailProviders() {
imapEncryption: formData.imapEncryption,
smtpEncryption: formData.smtpEncryption,
allowSelfSignedCerts: formData.allowSelfSignedCerts,
systemEmailAddress: formData.systemEmailAddress,
isActive: formData.isActive,
isDefault: formData.isDefault,
};
@@ -231,6 +240,11 @@ export default function EmailProviders() {
data.password = formData.password;
}
// System-E-Mail-Passwort nur senden wenn eingegeben
if (formData.systemEmailPassword) {
(data as any).systemEmailPassword = formData.systemEmailPassword;
}
if (editingId) {
updateMutation.mutate({ id: editingId, data });
} else {
@@ -324,6 +338,10 @@ export default function EmailProviders() {
<dt className="text-gray-500">Standard-Weiterleitung</dt>
<dd className="truncate">{config.defaultForwardEmail || '-'}</dd>
</div>
<div>
<dt className="text-gray-500">System-E-Mail</dt>
<dd className="truncate">{config.systemEmailAddress || <span className="text-amber-500">nicht konfiguriert</span>}</dd>
</div>
</dl>
{/* Test-Ergebnis für diesen Provider */}
@@ -547,6 +565,46 @@ export default function EmailProviders() {
</div>
</div>
{/* System-E-Mail */}
<div className="pt-4 border-t">
<div className="flex items-center gap-2 mb-3">
<Mail className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">System-E-Mail für automatischen Versand</h4>
</div>
<p className="text-xs text-gray-500 mb-3">
Diese E-Mail-Adresse wird für automatisierte Nachrichten verwendet (z.B. DSGVO Consent-Links). Der Kunden-E-Mail-Client wird davon nicht beeinflusst.
</p>
<div className="grid grid-cols-2 gap-4">
<Input
label="System-E-Mail-Adresse"
value={formData.systemEmailAddress}
onChange={(e) => setFormData({ ...formData, systemEmailAddress: e.target.value })}
placeholder="info@stressfrei-wechseln.de"
type="email"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{editingId ? 'Neues Passwort (leer = beibehalten)' : 'Passwort'}
</label>
<div className="relative">
<input
type={showSystemPassword ? 'text' : 'password'}
value={formData.systemEmailPassword}
onChange={(e) => setFormData({ ...formData, systemEmailPassword: e.target.value })}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowSystemPassword(!showSystemPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showSystemPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -0,0 +1,391 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { gdprApi } from '../../services/api';
import type { DataDeletionRequest, DeletionRequestStatus } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Select from '../../components/ui/Select';
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'PENDING', label: 'Ausstehend' },
{ value: 'IN_PROGRESS', label: 'In Bearbeitung' },
{ value: 'COMPLETED', label: 'Abgeschlossen' },
{ value: 'PARTIALLY_COMPLETED', label: 'Teilweise abgeschlossen' },
{ value: 'REJECTED', label: 'Abgelehnt' },
];
function getStatusBadge(status: DeletionRequestStatus) {
switch (status) {
case 'PENDING':
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800"><Clock className="w-3 h-3" /> Ausstehend</span>;
case 'IN_PROGRESS':
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800"><Clock className="w-3 h-3" /> In Bearbeitung</span>;
case 'COMPLETED':
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800"><CheckCircle className="w-3 h-3" /> Abgeschlossen</span>;
case 'PARTIALLY_COMPLETED':
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-orange-100 text-orange-800"><AlertTriangle className="w-3 h-3" /> Teilweise</span>;
case 'REJECTED':
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800"><XCircle className="w-3 h-3" /> Abgelehnt</span>;
default:
return <span className="px-2 py-1 rounded text-xs bg-gray-100">{status}</span>;
}
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
interface ProcessModalProps {
request: DataDeletionRequest;
onClose: () => void;
onProcess: (action: 'complete' | 'partial' | 'reject', reason?: string) => void;
isPending: boolean;
}
function ProcessModal({ request, onClose, onProcess, isPending }: ProcessModalProps) {
const [action, setAction] = useState<'complete' | 'partial' | 'reject'>('complete');
const [reason, setReason] = useState('');
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Löschanfrage bearbeiten</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="mb-4 p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Kunde:</p>
<p className="font-medium">
{request.customer?.firstName} {request.customer?.lastName} ({request.customer?.customerNumber})
</p>
<p className="text-sm text-gray-600 mt-2">Quelle: {request.requestSource}</p>
<p className="text-sm text-gray-600">Angefordert: {formatDate(request.requestedAt)}</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Aktion</label>
<div className="space-y-2">
<label className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="action"
checked={action === 'complete'}
onChange={() => setAction('complete')}
className="text-blue-600"
/>
<div>
<div className="font-medium">Vollständig löschen</div>
<div className="text-sm text-gray-500">Alle Kundendaten werden anonymisiert</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="action"
checked={action === 'partial'}
onChange={() => setAction('partial')}
className="text-blue-600"
/>
<div>
<div className="font-medium">Teilweise löschen</div>
<div className="text-sm text-gray-500">Nur optionale Daten werden gelöscht (aktive Verträge bleiben)</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="action"
checked={action === 'reject'}
onChange={() => setAction('reject')}
className="text-blue-600"
/>
<div>
<div className="font-medium">Ablehnen</div>
<div className="text-sm text-gray-500">Löschanfrage kann nicht durchgeführt werden</div>
</div>
</label>
</div>
</div>
{(action === 'partial' || action === 'reject') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Begründung {action === 'reject' && '*'}
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Grund für die teilweise Löschung/Ablehnung..."
required={action === 'reject'}
/>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button
onClick={() => onProcess(action, reason || undefined)}
disabled={isPending || (action === 'reject' && !reason)}
>
{isPending ? 'Verarbeite...' : 'Durchführen'}
</Button>
</div>
</div>
</div>
</div>
);
}
export default function GDPRDashboard() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<DeletionRequestStatus | ''>('');
const [selectedRequest, setSelectedRequest] = useState<DataDeletionRequest | null>(null);
const { data: statsData } = useQuery({
queryKey: ['gdpr-stats'],
queryFn: () => gdprApi.getDashboardStats(),
});
const { data: requestsData, isLoading } = useQuery({
queryKey: ['deletion-requests', statusFilter],
queryFn: () => gdprApi.getDeletionRequests({ status: statusFilter || undefined }),
});
const { data: consentData } = useQuery({
queryKey: ['consent-overview'],
queryFn: () => gdprApi.getConsentOverview(),
});
const processMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: { processedBy: string; action: 'complete' | 'partial' | 'reject'; retentionReason?: string } }) =>
gdprApi.processDeletionRequest(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deletion-requests'] });
queryClient.invalidateQueries({ queryKey: ['gdpr-stats'] });
setSelectedRequest(null);
},
});
const stats = statsData?.data;
const requests = requestsData?.data || [];
// Backend gibt ein Array mit { type, label, description, granted, withdrawn, pending } zurück
const consentsList: Array<{ type: string; label?: string; description?: string; granted: number; withdrawn: number; pending: number }> =
Array.isArray(consentData?.data) ? consentData.data : [];
const handleProcess = (action: 'complete' | 'partial' | 'reject', reason?: string) => {
if (!selectedRequest) return;
const user = JSON.parse(localStorage.getItem('user') || '{}');
processMutation.mutate({
id: selectedRequest.id,
data: {
processedBy: user.email || 'System',
action,
retentionReason: reason,
},
});
};
const consentLabels: Record<string, string> = {
DATA_PROCESSING: 'Datenverarbeitung',
MARKETING_EMAIL: 'E-Mail-Marketing',
MARKETING_PHONE: 'Telefonmarketing',
DATA_SHARING_PARTNER: 'Datenweitergabe',
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<h1 className="text-2xl font-bold">DSGVO-Dashboard</h1>
</div>
{/* Statistik-Kacheln */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card>
<div className="flex items-center gap-4">
<div className="p-3 bg-yellow-100 rounded-lg">
<Clock className="w-6 h-6 text-yellow-600" />
</div>
<div>
<div className="text-2xl font-bold">{stats?.deletionRequests.pending ?? '-'}</div>
<div className="text-sm text-gray-500">Offene Löschanfragen</div>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600" />
</div>
<div>
<div className="text-2xl font-bold">{stats?.deletionRequests.completedLast30Days ?? '-'}</div>
<div className="text-sm text-gray-500">Gelöscht (30 Tage)</div>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="p-3 bg-purple-100 rounded-lg">
<Download className="w-6 h-6 text-purple-600" />
</div>
<div>
<div className="text-2xl font-bold">{stats?.dataExports.last30Days ?? '-'}</div>
<div className="text-sm text-gray-500">Datenexporte (30 Tage)</div>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Users className="w-6 h-6 text-blue-600" />
</div>
<div>
<div className="text-2xl font-bold">{stats?.consents.granted ?? '-'}</div>
<div className="text-sm text-gray-500">Aktive Einwilligungen</div>
</div>
</div>
</Card>
</div>
{/* Consent-Übersicht */}
{consentsList.length > 0 && (
<Card className="mb-6">
<h2 className="text-lg font-semibold mb-4">Einwilligungen nach Typ</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{consentsList.map((item) => (
<div key={item.type} className="p-4 bg-gray-50 rounded-lg">
<div className="font-medium mb-2">{item.label || consentLabels[item.type] || item.type}</div>
<div className="flex gap-4 text-sm">
<div>
<span className="text-green-600 font-medium">{item.granted}</span>
<span className="text-gray-500 ml-1">erteilt</span>
</div>
<div>
<span className="text-red-600 font-medium">{item.withdrawn}</span>
<span className="text-gray-500 ml-1">widerrufen</span>
</div>
<div>
<span className="text-gray-600 font-medium">{item.pending}</span>
<span className="text-gray-500 ml-1">ausstehend</span>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Löschanfragen */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Löschanfragen</h2>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as DeletionRequestStatus | '')}
options={STATUS_OPTIONS}
className="w-48"
/>
</div>
{isLoading ? (
<div className="text-center py-8">Laden...</div>
) : requests.length === 0 ? (
<div className="text-center py-8 text-gray-500">Keine Löschanfragen gefunden.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Kunde</th>
<th className="text-left py-3 px-4">Status</th>
<th className="text-left py-3 px-4">Quelle</th>
<th className="text-left py-3 px-4">Angefordert</th>
<th className="text-left py-3 px-4">Bearbeitet</th>
<th className="text-center py-3 px-4"></th>
</tr>
</thead>
<tbody>
{requests.map((request) => (
<tr key={request.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
{request.customer ? (
<div>
<div className="font-medium">{request.customer.firstName} {request.customer.lastName}</div>
<div className="text-xs text-gray-500">{request.customer.customerNumber}</div>
</div>
) : (
<span className="text-gray-400">Kunde #{request.customerId}</span>
)}
</td>
<td className="py-3 px-4">{getStatusBadge(request.status)}</td>
<td className="py-3 px-4">{request.requestSource}</td>
<td className="py-3 px-4">
<div>{formatDate(request.requestedAt)}</div>
<div className="text-xs text-gray-500">von {request.requestedBy}</div>
</td>
<td className="py-3 px-4">
{request.processedAt ? (
<div>
<div>{formatDate(request.processedAt)}</div>
<div className="text-xs text-gray-500">von {request.processedBy}</div>
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="py-3 px-4 text-center">
{(request.status === 'PENDING' || request.status === 'IN_PROGRESS') && (
<Button variant="ghost" size="sm" onClick={() => setSelectedRequest(request)}>
<ChevronRight className="w-4 h-4" />
</Button>
)}
{request.proofDocument && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(`/api/uploads/${request.proofDocument}`, '_blank')}
title="Löschnachweis anzeigen"
>
<FileText className="w-4 h-4 text-blue-500" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Bearbeitungs-Modal */}
{selectedRequest && (
<ProcessModal
request={selectedRequest}
onClose={() => setSelectedRequest(null)}
onProcess={handleProcess}
isPending={processMutation.isPending}
/>
)}
</div>
);
}
@@ -0,0 +1,202 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import { gdprApi } from '../../services/api';
import Button from '../../components/ui/Button';
import Card from '../../components/ui/Card';
import { ArrowLeft, Save, Eye, Bold, Italic, List, ListOrdered, Heading1, Heading2, Heading3, Link as LinkIcon, Undo, Redo, Type } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
const PLACEHOLDERS = [
{ key: '{{vorname}}', label: 'Vorname' },
{ key: '{{nachname}}', label: 'Nachname' },
{ key: '{{kundennummer}}', label: 'Kundennummer' },
{ key: '{{anrede}}', label: 'Anrede' },
{ key: '{{email}}', label: 'E-Mail' },
{ key: '{{datum}}', label: 'Aktuelles Datum' },
];
function MenuBar({ editor }: { editor: ReturnType<typeof useEditor> }) {
if (!editor) return null;
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL eingeben:', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
const btnClass = (active: boolean) =>
`p-1.5 rounded hover:bg-gray-200 transition-colors ${active ? 'bg-gray-200 text-blue-600' : 'text-gray-600'}`;
return (
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-gray-50">
<button type="button" onClick={() => editor.chain().focus().toggleBold().run()} className={btnClass(editor.isActive('bold'))} title="Fett">
<Bold className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleItalic().run()} className={btnClass(editor.isActive('italic'))} title="Kursiv">
<Italic className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={btnClass(editor.isActive('heading', { level: 1 }))} title="Überschrift 1">
<Heading1 className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={btnClass(editor.isActive('heading', { level: 2 }))} title="Überschrift 2">
<Heading2 className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={btnClass(editor.isActive('heading', { level: 3 }))} title="Überschrift 3">
<Heading3 className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().setParagraph().run()} className={btnClass(editor.isActive('paragraph'))} title="Absatz">
<Type className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={() => editor.chain().focus().toggleBulletList().run()} className={btnClass(editor.isActive('bulletList'))} title="Aufzählung">
<List className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={btnClass(editor.isActive('orderedList'))} title="Nummerierung">
<ListOrdered className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={setLink} className={btnClass(editor.isActive('link'))} title="Link">
<LinkIcon className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-gray-300 mx-1" />
<button type="button" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Rückgängig">
<Undo className="w-4 h-4" />
</button>
<button type="button" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Wiederherstellen">
<Redo className="w-4 h-4" />
</button>
</div>
);
}
export default function PrivacyPolicyEditor() {
const queryClient = useQueryClient();
const [showPreview, setShowPreview] = useState(false);
const [saved, setSaved] = useState(false);
const { data: policyData, isLoading } = useQuery({
queryKey: ['privacy-policy'],
queryFn: () => gdprApi.getPrivacyPolicy(),
});
const saveMutation = useMutation({
mutationFn: (html: string) => gdprApi.updatePrivacyPolicy(html),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['privacy-policy'] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
}),
],
content: policyData?.data?.html || '',
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none p-4 min-h-[400px] focus:outline-none',
},
},
}, [policyData?.data?.html]);
const insertPlaceholder = (key: string) => {
if (editor) {
editor.chain().focus().insertContent(key).run();
}
};
const handleSave = () => {
if (editor) {
saveMutation.mutate(editor.getHTML());
}
};
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Laden...</div>;
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<RouterLink to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</RouterLink>
<h1 className="text-2xl font-bold flex-1">Datenschutzerklärung bearbeiten</h1>
<Button
variant="secondary"
onClick={() => setShowPreview(!showPreview)}
>
<Eye className="w-4 h-4 mr-2" />
{showPreview ? 'Editor' : 'Vorschau'}
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
{saved ? 'Gespeichert!' : saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Editor / Vorschau */}
<div className="lg:col-span-3">
{showPreview ? (
<Card>
<div
className="prose prose-sm max-w-none p-4"
dangerouslySetInnerHTML={{ __html: editor?.getHTML() || '' }}
/>
</Card>
) : (
<div className="border rounded-lg bg-white overflow-hidden">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
)}
</div>
{/* Platzhalter-Sidebar */}
<div className="lg:col-span-1">
<Card title="Platzhalter">
<p className="text-xs text-gray-500 mb-3">
Klicken Sie auf einen Platzhalter, um ihn an der Cursorposition einzufügen.
</p>
<div className="space-y-2">
{PLACEHOLDERS.map((p) => (
<button
key={p.key}
onClick={() => insertPlaceholder(p.key)}
className="w-full text-left px-3 py-2 text-sm bg-gray-50 border rounded hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<span className="font-mono text-blue-600 text-xs">{p.key}</span>
<br />
<span className="text-gray-600">{p.label}</span>
</button>
))}
</div>
</Card>
{saveMutation.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>
</div>
</div>
);
}
+70 -5
View File
@@ -7,7 +7,7 @@ import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft, Info } from 'lucide-react';
import { Plus, Edit, Trash2, Search, Code, Shield, AlertTriangle, ArrowLeft, Info } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { User, Role } from '../../types';
@@ -121,7 +121,7 @@ export default function UserList() {
<td className="py-3 px-4">{user.email}</td>
<td className="py-3 px-4">
<div className="flex gap-1 flex-wrap">
{user.roles?.filter((role: any) => role.name !== 'Developer').map((role: any) => (
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
<Badge key={role.id || role.name} variant="info">
{role.name}
</Badge>
@@ -139,6 +139,12 @@ export default function UserList() {
Dev
</Badge>
)}
{(user as any).hasGdprAccess && (
<Badge variant="info" className="flex items-center gap-1">
<Shield className="w-3 h-3" />
DSGVO
</Badge>
)}
</div>
</td>
<td className="py-3 px-4 text-right">
@@ -237,6 +243,10 @@ function UserModal({
roleIds: [] as number[],
isActive: true,
hasDeveloperAccess: false,
hasGdprAccess: false,
whatsappNumber: '',
telegramUsername: '',
signalNumber: '',
});
// Reset form when modal opens or user changes
@@ -249,9 +259,13 @@ function UserModal({
password: '',
firstName: user.firstName,
lastName: user.lastName,
roleIds: user.roles?.filter((r: any) => r.name !== 'Developer').map((r: any) => r.id) || [],
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
isActive: (user as any).isActive ?? true,
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
hasGdprAccess: (user as any).hasGdprAccess ?? false,
whatsappNumber: (user as any).whatsappNumber || '',
telegramUsername: (user as any).telegramUsername || '',
signalNumber: (user as any).signalNumber || '',
});
} else {
setFormData({
@@ -262,6 +276,10 @@ function UserModal({
roleIds: [],
isActive: true,
hasDeveloperAccess: false,
hasGdprAccess: false,
whatsappNumber: '',
telegramUsername: '',
signalNumber: '',
});
}
}
@@ -300,6 +318,10 @@ function UserModal({
roleIds: formData.roleIds,
isActive: formData.isActive,
hasDeveloperAccess: formData.hasDeveloperAccess,
hasGdprAccess: formData.hasGdprAccess,
whatsappNumber: formData.whatsappNumber || undefined,
telegramUsername: formData.telegramUsername || undefined,
signalNumber: formData.signalNumber || undefined,
};
if (formData.password) {
updateData.password = formData.password;
@@ -313,6 +335,10 @@ function UserModal({
lastName: formData.lastName,
roleIds: formData.roleIds,
hasDeveloperAccess: formData.hasDeveloperAccess,
hasGdprAccess: formData.hasGdprAccess,
whatsappNumber: formData.whatsappNumber || undefined,
telegramUsername: formData.telegramUsername || undefined,
signalNumber: formData.signalNumber || undefined,
});
}
};
@@ -372,10 +398,34 @@ function UserModal({
required={!user}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
<div className="space-y-3">
<Input
label="WhatsApp-Nummer"
placeholder="+49..."
value={formData.whatsappNumber}
onChange={(e) => setFormData({ ...formData, whatsappNumber: e.target.value })}
/>
<Input
label="Telegram-Benutzername"
placeholder="@username"
value={formData.telegramUsername}
onChange={(e) => setFormData({ ...formData, telegramUsername: e.target.value })}
/>
<Input
label="Signal-Nummer"
placeholder="+49..."
value={formData.signalNumber}
onChange={(e) => setFormData({ ...formData, signalNumber: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
<div className="space-y-2">
{roles.filter((role) => role.name !== 'Developer').map((role) => (
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
<label key={role.id} className="flex items-center gap-2">
<input
type="checkbox"
@@ -389,7 +439,22 @@ function UserModal({
)}
</label>
))}
{/* Entwicklerzugriff direkt unter den Rollen */}
</div>
<label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label>
<div className="space-y-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.hasGdprAccess}
onChange={(e) => setFormData({ ...formData, hasGdprAccess: e.target.checked })}
className="rounded border-blue-300 text-blue-600 focus:ring-blue-500"
/>
<span className="flex items-center gap-1">
<Shield className="w-4 h-4 text-blue-600" />
DSGVO-Zugriff
</span>
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
+267 -2
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, ContractHistoryEntry } from '../types';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
const api = axios.create({
baseURL: '/api',
@@ -206,6 +206,20 @@ export const meterApi = {
const res = await api.delete<ApiResponse<void>>(`/meters/${meterId}/readings/${readingId}`);
return res.data;
},
// Portal: Zählerstand melden
reportReading: async (meterId: number, data: { value: number; readingDate?: string; notes?: string }) => {
const res = await api.post<ApiResponse<MeterReading>>(`/meters/${meterId}/readings/report`, data);
return res.data;
},
getMyMeters: async () => {
const res = await api.get<ApiResponse<Meter[]>>('/meters/my-meters');
return res.data;
},
// Status-Update
markTransferred: async (meterId: number, readingId: number) => {
const res = await api.patch<ApiResponse<MeterReading>>(`/meters/${meterId}/readings/${readingId}/transfer`);
return res.data;
},
};
// Invoice API
@@ -1070,7 +1084,7 @@ export const userApi = {
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
return res.data;
},
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean }) => {
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
const res = await api.post<ApiResponse<User>>('/users', data);
return res.data;
},
@@ -1132,6 +1146,9 @@ export interface EmailProviderConfig {
imapEncryption?: 'SSL' | 'STARTTLS' | 'NONE';
smtpEncryption?: 'SSL' | 'STARTTLS' | 'NONE';
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
// System-E-Mail für automatisierten Versand
systemEmailAddress?: string;
systemEmailPasswordEncrypted?: string;
isActive: boolean;
isDefault: boolean;
createdAt: string;
@@ -1204,4 +1221,252 @@ export const emailProviderApi = {
},
};
// ==================== AUDIT-LOGS ====================
export interface AuditLogSearchParams {
page?: number;
limit?: number;
userId?: number;
action?: string;
resourceType?: string;
sensitivity?: AuditSensitivity;
dataSubjectId?: number;
startDate?: string;
endDate?: string;
search?: string;
}
export const auditLogApi = {
search: async (params?: AuditLogSearchParams) => {
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<AuditLog>>(`/audit-logs/${id}`);
return res.data;
},
getByCustomer: async (customerId: number) => {
const res = await api.get<ApiResponse<AuditLog[]>>(`/audit-logs/customer/${customerId}`);
return res.data;
},
export: async (params?: AuditLogSearchParams & { format?: 'json' | 'csv' }) => {
const res = await api.get<ApiResponse<{ data: AuditLog[]; format: string }>>('/audit-logs/export', { params });
return res.data;
},
verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; errors: string[] }>>('/audit-logs/verify');
return res.data;
},
getRetentionPolicies: async () => {
const res = await api.get<ApiResponse<AuditRetentionPolicy[]>>('/audit-logs/retention-policies');
return res.data;
},
updateRetentionPolicy: async (id: number, data: { retentionDays: number; isActive?: boolean }) => {
const res = await api.put<ApiResponse<AuditRetentionPolicy>>(`/audit-logs/retention-policies/${id}`, data);
return res.data;
},
runRetentionCleanup: async () => {
const res = await api.post<ApiResponse<{ deletedCount: number }>>('/audit-logs/cleanup');
return res.data;
},
};
// ==================== EMAIL LOG ====================
export interface EmailLog {
id: number;
fromAddress: string;
toAddress: string;
subject: string;
context: string;
customerId?: number;
triggeredBy?: string;
smtpServer: string;
smtpPort: number;
smtpEncryption: string;
smtpUser: string;
success: boolean;
messageId?: string;
errorMessage?: string;
smtpResponse?: string;
sentAt: string;
}
export const emailLogApi = {
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
const query = new URLSearchParams();
if (params?.page) query.set('page', params.page.toString());
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.success !== undefined) query.set('success', params.success);
if (params?.search) query.set('search', params.search);
if (params?.context) query.set('context', params.context);
const res = await api.get<ApiResponse<EmailLog[]> & { pagination: { page: number; limit: number; total: number; totalPages: number } }>(`/email-logs?${query}`);
return res.data;
},
getStats: async () => {
const res = await api.get<ApiResponse<{ total: number; success: number; failed: number; last24h: number }>>('/email-logs/stats');
return res.data;
},
getDetail: async (id: number) => {
const res = await api.get<ApiResponse<EmailLog>>(`/email-logs/${id}`);
return res.data;
},
};
// ==================== DSGVO ====================
export const gdprApi = {
// Dashboard
getDashboardStats: async () => {
const res = await api.get<ApiResponse<GDPRDashboardStats>>('/gdpr/dashboard');
return res.data;
},
// Datenexport (Art. 15)
exportCustomerData: async (customerId: number) => {
const res = await api.get<ApiResponse<unknown>>(`/gdpr/customer/${customerId}/export`);
return res.data;
},
// Löschanfragen
getDeletionRequests: async (params?: { status?: DeletionRequestStatus; page?: number; limit?: number }) => {
const res = await api.get<ApiResponse<DataDeletionRequest[]>>('/gdpr/deletions', { params });
return res.data;
},
getDeletionRequest: async (id: number) => {
const res = await api.get<ApiResponse<DataDeletionRequest>>(`/gdpr/deletions/${id}`);
return res.data;
},
createDeletionRequest: async (data: { customerId: number; requestSource: string; requestedBy: string }) => {
const res = await api.post<ApiResponse<DataDeletionRequest>>('/gdpr/deletions', data);
return res.data;
},
processDeletionRequest: async (id: number, data: { processedBy: string; action: 'complete' | 'partial' | 'reject'; retentionReason?: string }) => {
const res = await api.put<ApiResponse<DataDeletionRequest>>(`/gdpr/deletions/${id}/process`, data);
return res.data;
},
// Consent-Status prüfen (hat Kunde vollständig zugestimmt?)
checkConsentStatus: async (customerId: number) => {
const res = await api.get<ApiResponse<{ hasConsent: boolean; hasPaperConsent: boolean; hasOnlineConsent: boolean; consentDetails: { type: string; status: string }[]; consentHash: string | null }>>(`/gdpr/customer/${customerId}/consent-status`);
return res.data;
},
getMyConsentStatus: async () => {
const res = await api.get<ApiResponse<{ hasConsent: boolean; hasPaperConsent: boolean; hasOnlineConsent: boolean; consentDetails: { type: string; status: string }[]; consentHash: string | null }>>('/gdpr/my-consent-status');
return res.data;
},
// Einwilligungen
getCustomerConsents: async (customerId: number) => {
const res = await api.get<ApiResponse<CustomerConsent[]>>(`/gdpr/customer/${customerId}/consents`);
return res.data;
},
updateConsent: async (customerId: number, consentType: ConsentType, data: { status: ConsentStatus; source?: string }) => {
const res = await api.put<ApiResponse<CustomerConsent>>(`/gdpr/customer/${customerId}/consents/${consentType}`, data);
return res.data;
},
getConsentOverview: async () => {
const res = await api.get<ApiResponse<Record<string, { granted: number; withdrawn: number; pending: number }>>>('/gdpr/consents/overview');
return res.data;
},
// Datenschutzerklärung (Editor)
getPrivacyPolicy: async () => {
const res = await api.get<ApiResponse<{ html: string }>>('/gdpr/privacy-policy');
return res.data;
},
updatePrivacyPolicy: async (html: string) => {
const res = await api.put<ApiResponse<void>>('/gdpr/privacy-policy', { html });
return res.data;
},
// Vollmacht-Vorlage
getAuthorizationTemplate: async () => {
const res = await api.get<ApiResponse<{ html: string }>>('/gdpr/authorization-template');
return res.data;
},
updateAuthorizationTemplate: async (html: string) => {
const res = await api.put<ApiResponse<void>>('/gdpr/authorization-template', { html });
return res.data;
},
// Consent-Link senden
sendConsentLink: async (customerId: number, channel: string) => {
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/gdpr/customer/${customerId}/send-consent-link`, { channel });
return res.data;
},
// Portal: Eigene Datenschutzseite
getMyPrivacy: async () => {
const res = await api.get<ApiResponse<{ privacyPolicyHtml: string; consents: CustomerConsent[] }>>('/gdpr/my-privacy');
return res.data;
},
getMyPrivacyPdfUrl: '/api/gdpr/my-privacy/pdf',
// Vollmachten (Admin)
getAuthorizations: async (customerId: number) => {
const res = await api.get<ApiResponse<RepresentativeAuthorization[]>>(`/gdpr/customer/${customerId}/authorizations`);
return res.data;
},
sendAuthorizationRequest: async (customerId: number, representativeId: number, channel: string) => {
const res = await api.post<ApiResponse<{ channel: string; portalUrl: string; messageText: string }>>(`/gdpr/customer/${customerId}/authorizations/${representativeId}/send`, { channel });
return res.data;
},
grantAuthorization: async (customerId: number, representativeId: number, data?: { source?: string; notes?: string }) => {
const res = await api.post<ApiResponse<RepresentativeAuthorization>>(`/gdpr/customer/${customerId}/authorizations/${representativeId}/grant`, data || {});
return res.data;
},
withdrawAuthorization: async (customerId: number, representativeId: number) => {
const res = await api.post<ApiResponse<RepresentativeAuthorization>>(`/gdpr/customer/${customerId}/authorizations/${representativeId}/withdraw`);
return res.data;
},
uploadAuthorizationDocument: async (customerId: number, representativeId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<RepresentativeAuthorization>>(
`/gdpr/customer/${customerId}/authorizations/${representativeId}/upload`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
return res.data;
},
deleteAuthorizationDocument: async (customerId: number, representativeId: number) => {
const res = await api.delete<ApiResponse<RepresentativeAuthorization>>(`/gdpr/customer/${customerId}/authorizations/${representativeId}/document`);
return res.data;
},
// Vollmachten (Portal)
getMyAuthorizations: async () => {
const res = await api.get<ApiResponse<RepresentativeAuthorization[]>>('/gdpr/my-authorizations');
return res.data;
},
toggleMyAuthorization: async (representativeId: number, grant: boolean) => {
const res = await api.put<ApiResponse<RepresentativeAuthorization>>(`/gdpr/my-authorizations/${representativeId}`, { grant });
return res.data;
},
getMyAuthorizationStatus: async () => {
const res = await api.get<ApiResponse<{ customerId: number; hasAuthorization: boolean }[]>>('/gdpr/my-authorization-status');
return res.data;
},
};
// ==================== PUBLIC API (kein Auth-Token) ====================
const publicAxios = axios.create({
baseURL: '/api/public',
headers: { 'Content-Type': 'application/json' },
});
export const publicApi = {
getConsentPage: async (hash: string) => {
const res = await publicAxios.get<ApiResponse<{
customer: { firstName: string; lastName: string; customerNumber: string };
consents: Array<{
consentType: string;
status: string;
label: string;
description: string;
grantedAt: string | null;
}>;
privacyPolicyHtml: string;
}>>(`/consent/${hash}`);
return res.data;
},
grantAllConsents: async (hash: string) => {
const res = await publicAxios.post<ApiResponse<void>>(`/consent/${hash}/grant`);
return res.data;
},
getConsentPdfUrl: (hash: string) => `/api/public/consent/${hash}/pdf`,
};
export default api;
+166
View File
@@ -8,6 +8,9 @@ export interface User {
roles?: Role[];
isCustomerPortal?: boolean;
representedCustomers?: CustomerSummary[];
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}
// Zusammenfassung für Vertreter-Listen
@@ -20,6 +23,22 @@ export interface CustomerSummary {
type: 'PRIVATE' | 'BUSINESS';
}
export interface RepresentativeAuthorization {
id: number;
customerId: number;
representativeId: number;
isGranted: boolean;
grantedAt?: string;
withdrawnAt?: string;
source?: string;
documentPath?: string;
notes?: string;
representative?: CustomerSummary;
customer?: CustomerSummary;
createdAt: string;
updatedAt: string;
}
export interface Role {
id: number;
name: string;
@@ -143,6 +162,8 @@ export interface Meter {
readings?: MeterReading[];
}
export type MeterReadingStatus = 'RECORDED' | 'REPORTED' | 'TRANSFERRED';
export interface MeterReading {
id: number;
meterId: number;
@@ -150,6 +171,10 @@ export interface MeterReading {
value: number;
unit: string;
notes?: string;
reportedBy?: string;
status: MeterReadingStatus;
transferredAt?: string;
transferredBy?: string;
}
export type InvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
@@ -522,11 +547,45 @@ export interface CockpitSummary {
openTasks: number;
pendingContracts: number;
reviewDue: number;
missingConsents: number;
};
}
export interface DocumentAlert {
id: number;
type: string;
documentNumber: string;
expiryDate: string;
daysUntilExpiry: number;
urgency: CockpitUrgencyLevel;
customer: {
id: number;
customerNumber: string;
name: string;
};
}
export interface ReportedMeterReading {
id: number;
readingDate: string;
value: number;
unit: string;
notes?: string;
reportedBy?: string;
createdAt: string;
meter: { id: number; meterNumber: string; type: string };
customer: { id: number; customerNumber: string; name: string };
providerPortal?: {
providerName: string;
portalUrl: string;
portalUsername?: string;
};
}
export interface CockpitResult {
contracts: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;
thresholds: {
criticalDays: number;
@@ -534,3 +593,110 @@ export interface CockpitResult {
okDays: number;
};
}
// ==================== AUDIT-LOGGING & DSGVO ====================
export type AuditAction = 'CREATE' | 'READ' | 'UPDATE' | 'DELETE' | 'EXPORT' | 'ANONYMIZE' | 'LOGIN' | 'LOGOUT' | 'LOGIN_FAILED';
export type AuditSensitivity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export type ConsentType = 'DATA_PROCESSING' | 'MARKETING_EMAIL' | 'MARKETING_PHONE' | 'DATA_SHARING_PARTNER';
export type ConsentStatus = 'GRANTED' | 'WITHDRAWN' | 'PENDING';
export type DeletionRequestStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PARTIALLY_COMPLETED' | 'REJECTED';
export interface AuditLog {
id: number;
userId?: number;
userEmail: string;
userRole?: string;
customerId?: number;
isCustomerPortal: boolean;
action: AuditAction;
sensitivity: AuditSensitivity;
resourceType: string;
resourceId?: string;
resourceLabel?: string;
endpoint: string;
httpMethod: string;
ipAddress: string;
userAgent?: string;
changesBefore?: string;
changesAfter?: string;
changesEncrypted: boolean;
dataSubjectId?: number;
legalBasis?: string;
success: boolean;
errorMessage?: string;
durationMs?: number;
createdAt: string;
hash?: string;
previousHash?: string;
}
export interface CustomerConsent {
id: number | null;
customerId: number;
consentType: ConsentType;
status: ConsentStatus;
grantedAt?: string;
withdrawnAt?: string;
source?: string;
documentPath?: string;
version?: string;
ipAddress?: string;
createdBy?: string;
createdAt?: string;
updatedAt?: string;
}
export interface DataDeletionRequest {
id: number;
customerId: number;
customer?: {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email?: string;
};
status: DeletionRequestStatus;
requestedAt: string;
requestSource: string;
requestedBy: string;
processedAt?: string;
processedBy?: string;
deletedData?: string;
retainedData?: string;
retentionReason?: string;
proofDocument?: string;
createdAt: string;
updatedAt: string;
}
export interface AuditRetentionPolicy {
id: number;
resourceType: string;
sensitivity?: AuditSensitivity;
retentionDays: number;
description?: string;
legalBasis?: string;
isActive: boolean;
}
export interface GDPRDashboardStats {
deletionRequests: {
pending: number;
completedLast30Days: number;
};
dataExports: {
last30Days: number;
};
consents: {
granted: number;
withdrawn: number;
pending: number;
};
}
export interface ConsentTypeLabel {
label: string;
description: string;
}