gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, 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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user