Datenschutz vollmacht fixed, two time counter added

This commit is contained in:
2026-03-21 16:42:31 +01:00
parent 0121c82412
commit 4f359df161
56 changed files with 4401 additions and 789 deletions
@@ -1,6 +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 { pushHistory } from '../../utils/navigation';
import { contractApi, meterApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
@@ -282,7 +283,7 @@ export default function ContractCockpit() {
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/contracts/${contract.id}`}
state={{ from: '/contracts/cockpit' }}
state={pushHistory('/contracts/cockpit')}
className="font-medium hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -399,7 +400,7 @@ export default function ContractCockpit() {
<Link
to={`/contracts/${contract.id}`}
state={{ from: '/contracts/cockpit' }}
state={pushHistory('/contracts/cockpit')}
className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()}
title="Zum Vertrag"
@@ -610,6 +611,15 @@ export default function ContractCockpit() {
{reading.customer.name}
</Link>
<span className="text-xs text-gray-500">({reading.customer.customerNumber})</span>
{reading.contract && (
<Link
to={`/contracts/${reading.contract.id}`}
state={pushHistory('/contracts/cockpit')}
className="text-xs text-blue-600 hover:underline"
>
{reading.contract.contractNumber}
</Link>
)}
</div>
<p className="text-xs text-gray-600">
Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '}
@@ -635,6 +645,7 @@ export default function ContractCockpit() {
variant="secondary"
size="sm"
onClick={async () => {
if (!confirm(`Zählerstand ${reading.value} ${reading.unit} (Zähler ${reading.meter.meterNumber}) als übertragen markieren?`)) return;
await meterApi.markTransferred(reading.meter.id, reading.id);
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
}}
+339 -65
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
@@ -13,9 +14,9 @@ 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, Lock, Shield } from 'lucide-react';
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
@@ -197,16 +198,19 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
function MeterReadingsSection({
meterId,
meterType,
tariffModel,
readings,
contractId,
canEdit,
label,
}: {
meterId: number;
meterType: 'ELECTRICITY' | 'GAS';
tariffModel?: string;
readings: MeterReading[];
contractId: number;
canEdit: boolean;
label?: string;
}) {
const isDualTariff = tariffModel === 'DUAL';
const [isExpanded, setIsExpanded] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [editingReading, setEditingReading] = useState<MeterReading | null>(null);
@@ -215,7 +219,7 @@ function MeterReadingsSection({
const deleteReadingMutation = useMutation({
mutationFn: (readingId: number) => meterApi.deleteReading(meterId, readingId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract'] });
},
});
@@ -231,7 +235,7 @@ function MeterReadingsSection({
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Zählerstände</h4>
<h4 className="text-sm font-medium text-gray-700">{label || 'Zählerstände'}</h4>
<Badge variant="default">{readings.length}</Badge>
</div>
<div className="flex items-center gap-2">
@@ -274,8 +278,12 @@ function MeterReadingsSection({
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
{reading.value.toLocaleString('de-DE')} {reading.unit}
<CopyButton value={reading.value.toString()} title="Nur Wert kopieren" />
{isDualTariff && reading.valueNt != null ? (
<>HT: {reading.value.toLocaleString('de-DE')} / NT: {reading.valueNt.toLocaleString('de-DE')} {reading.unit}</>
) : (
<>{reading.value.toLocaleString('de-DE')} {reading.unit}</>
)}
<CopyButton value={reading.value.toString()} title="Wert kopieren" />
</span>
{canEdit && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
@@ -324,9 +332,9 @@ function MeterReadingsSection({
setEditingReading(null);
}}
meterId={meterId}
contractId={contractId}
reading={editingReading}
defaultUnit={defaultUnit}
tariffModel={tariffModel}
/>
)}
</div>
@@ -334,22 +342,135 @@ function MeterReadingsSection({
}
// Meter Reading Modal Component
function SuccessorMeterButton({
contractId,
customerId,
meterType,
existingMeterIds,
}: {
contractId: number;
customerId: number;
meterType: string;
existingMeterIds: number[];
}) {
const [showForm, setShowForm] = useState(false);
const [selectedMeterId, setSelectedMeterId] = useState('');
const [installedAt, setInstalledAt] = useState(new Date().toISOString().split('T')[0]);
const [finalReading, setFinalReading] = useState('');
const queryClient = useQueryClient();
const { data: metersData } = useQuery({
queryKey: ['customer-meters', customerId],
queryFn: () => meterApi.getByCustomer(customerId),
enabled: showForm,
});
const addMutation = useMutation({
mutationFn: (data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) =>
contractApi.addSuccessorMeter(contractId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract'] });
setShowForm(false);
setSelectedMeterId('');
setFinalReading('');
},
});
// Nur Zähler gleichen Typs anzeigen die noch nicht am Vertrag sind
const availableMeters = (metersData?.data || []).filter(
(m) => m.type === meterType && !existingMeterIds.includes(m.id) && m.isActive
);
if (!showForm) {
return (
<div className="mt-3 pt-3 border-t">
<Button variant="secondary" size="sm" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1" />
Folgezähler hinzufügen
</Button>
</div>
);
}
return (
<div className="mt-3 pt-3 border-t bg-blue-50 rounded-lg p-4">
<h4 className="text-sm font-medium mb-3">Folgezähler hinzufügen (Zählerwechsel)</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Zähler *</label>
<select
value={selectedMeterId}
onChange={(e) => setSelectedMeterId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Bitte wählen...</option>
{availableMeters.map((m) => (
<option key={m.id} value={m.id}>
{m.meterNumber} {m.location ? `(${m.location})` : ''}
</option>
))}
</select>
{availableMeters.length === 0 && (
<p className="text-xs text-amber-600 mt-1">Kein passender Zähler verfügbar. Bitte zuerst beim Kunden anlegen.</p>
)}
</div>
<Input
label="Wechseldatum"
type="date"
value={installedAt}
onChange={(e) => setInstalledAt(e.target.value)}
/>
<Input
label="Letzter Stand alter Zähler"
type="number"
step="0.01"
value={finalReading}
onChange={(e) => setFinalReading(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="flex gap-2 mt-3">
<Button
size="sm"
onClick={() => addMutation.mutate({
meterId: parseInt(selectedMeterId),
installedAt,
finalReadingPrevious: finalReading ? parseFloat(finalReading) : undefined,
})}
disabled={!selectedMeterId || addMutation.isPending}
>
{addMutation.isPending ? 'Speichern...' : 'Hinzufügen'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>
Abbrechen
</Button>
</div>
{addMutation.isError && (
<p className="text-xs text-red-600 mt-2">
{addMutation.error instanceof Error ? addMutation.error.message : 'Fehler beim Hinzufügen'}
</p>
)}
</div>
);
}
function MeterReadingModal({
isOpen,
onClose,
meterId,
contractId,
reading,
defaultUnit,
tariffModel,
}: {
isOpen: boolean;
onClose: () => void;
meterId: number;
contractId: number;
reading?: MeterReading | null;
defaultUnit: string;
tariffModel?: string;
}) {
const queryClient = useQueryClient();
const isDualTariff = tariffModel === 'DUAL';
const isEditing = !!reading;
const [formData, setFormData] = useState({
@@ -357,33 +478,47 @@ function MeterReadingModal({
? new Date(reading.readingDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
value: reading?.value?.toString() || '',
valueNt: reading?.valueNt?.toString() || '',
notes: reading?.notes || '',
});
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract'] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<MeterReading>) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract'] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
const data: Record<string, unknown> = {
readingDate: new Date(formData.readingDate),
value: parseFloat(formData.value),
unit: defaultUnit,
notes: formData.notes || undefined,
};
if (isDualTariff && formData.valueNt) {
data.valueNt = parseFloat(formData.valueNt);
}
if (isEditing) {
updateMutation.mutate(data as unknown as Partial<MeterReading>);
} else {
@@ -404,10 +539,10 @@ function MeterReadingModal({
required
/>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<div className={`grid ${isDualTariff ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
<div className={isDualTariff ? '' : 'col-span-2'}>
<Input
label="Zählerstand"
label={isDualTariff ? 'HT-Stand (Hochtarif)' : 'Zählerstand'}
type="number"
step="0.01"
value={formData.value}
@@ -415,12 +550,26 @@ function MeterReadingModal({
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
{isDualTariff && (
<div>
<Input
label="NT-Stand (Niedertarif)"
type="number"
step="0.01"
value={formData.valueNt}
onChange={(e) => setFormData({ ...formData, valueNt: e.target.value })}
required
/>
</div>
</div>
)}
{!isDualTariff && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einheit</label>
<div className="h-10 flex items-center px-3 bg-gray-100 border border-gray-300 rounded-md text-gray-700">
{defaultUnit}
</div>
</div>
)}
</div>
<Input
@@ -429,6 +578,12 @@ function MeterReadingModal({
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
/>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
@@ -450,24 +605,67 @@ function EnergyConsumptionCalculation({
endDate,
basePrice,
unitPrice,
unitPriceNt,
bonus,
hasMeter,
contractMeters,
}: {
contractType: 'ELECTRICITY' | 'GAS';
readings: MeterReading[];
startDate: string;
endDate: string;
startDate?: string;
endDate?: string;
basePrice?: number;
unitPrice?: number;
bonus?: number;
unitPriceNt?: number;
hasMeter?: boolean;
contractMeters?: ContractMeter[];
}) {
// Berechnung durchführen
const consumption = calculateConsumption(readings, startDate, endDate, contractType);
const costs = consumption.consumptionKwh > 0
? calculateCosts(consumption.consumptionKwh, basePrice, unitPrice, bonus)
// Fehlende Felder ermitteln
const missingFields: string[] = [];
if (!startDate) missingFields.push('Vertragsbeginn');
if (!endDate) missingFields.push('Vertragsende');
if (!hasMeter) missingFields.push('Zähler verknüpft');
if (basePrice == null) missingFields.push('Grundpreis');
if (unitPrice == null) missingFields.push('Arbeitspreis');
const hasRequiredDates = !!startDate && !!endDate;
// Alle Readings über alle Zähler zählen
const allReadingsCount = contractMeters && contractMeters.length > 0
? contractMeters.reduce((sum, cm) => sum + (cm.meter?.readings?.length || 0), 0)
: readings.length;
if (hasRequiredDates && hasMeter && allReadingsCount < 2) {
missingFields.push(`Mindestens 2 Zählerstände im Vertragszeitraum (${allReadingsCount} vorhanden)`);
}
// Berechnung: Multi-Zähler wenn vorhanden, sonst Single
let consumption = null;
if (hasRequiredDates) {
if (contractMeters && contractMeters.length > 1) {
consumption = calculateMultiMeterConsumption(contractMeters, startDate!, endDate!, contractType);
} else {
consumption = calculateConsumption(readings, startDate!, endDate!, contractType);
}
}
const htKwh = consumption?.consumptionHt ?? consumption?.consumptionKwh ?? 0;
const ntKwh = consumption?.consumptionNt;
const costs = consumption && consumption.consumptionKwh > 0
? calculateCosts(htKwh, basePrice, unitPrice, bonus, ntKwh, unitPriceNt)
: null;
// Nichts anzeigen wenn keine Daten
if (consumption.type === 'none') return null;
const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected');
// Wenn Berechnung fehlschlägt aber keine Felder fehlen → Hinweis aus der Berechnung anzeigen
if (!canCalculate && consumption && missingFields.length === 0) {
if (consumption.type === 'insufficient' && consumption.message) {
missingFields.push(consumption.message);
} else if (consumption.type === 'none') {
missingFields.push('Keine Zählerstände im Vertragszeitraum vorhanden');
}
}
const formatNumber = (num: number, decimals: number = 2) =>
num.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
@@ -481,40 +679,61 @@ function EnergyConsumptionCalculation({
<div className="flex items-center gap-2 mb-3">
<Calculator className="w-4 h-4 text-gray-500" />
<h4 className="text-sm font-medium text-gray-700">Verbrauch & Kosten</h4>
{consumption.type === 'exact' && (
{canCalculate && consumption!.type === 'exact' && (
<Badge variant="success">Exakt</Badge>
)}
{consumption.type === 'projected' && (
{canCalculate && consumption!.type === 'projected' && (
<Badge variant="warning">Hochrechnung</Badge>
)}
</div>
{/* Fall C: Unzureichende Daten */}
{consumption.type === 'insufficient' ? (
<p className="text-sm text-gray-500 italic">{consumption.message}</p>
{/* Fehlende Daten */}
{!canCalculate ? (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-3">
Für die Berechnung fehlen noch folgende Angaben:
</p>
<ul className="space-y-1.5">
{missingFields.map((field) => (
<li key={field} className="flex items-center gap-2 text-sm">
<Circle className="w-3 h-3 text-gray-300 flex-shrink-0" />
<span className="text-gray-500">{field}</span>
</li>
))}
</ul>
{consumption?.type === 'insufficient' && (
<p className="text-sm text-amber-600 mt-3 italic">{consumption.message}</p>
)}
</div>
) : (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
{/* Verbrauchsanzeige */}
<div>
<h5 className="text-sm font-medium text-gray-600 mb-2">
Berechneter Verbrauch
{consumption.type === 'projected' && ' (hochgerechnet)'}
{consumption!.type === 'projected' && ' (hochgerechnet)'}
</h5>
<div className="text-lg font-semibold text-gray-900">
{contractType === 'GAS' ? (
<>
<span className="font-mono">{formatNumber(consumption.consumptionM3 || 0)} m³</span>
<span className="font-mono">{formatNumber(consumption!.consumptionM3 || 0)} m³</span>
<span className="text-gray-500 text-sm ml-2">
= {formatNumber(consumption.consumptionKwh)} kWh
= {formatNumber(consumption!.consumptionKwh)} kWh
</span>
</>
) : consumption!.consumptionHt !== undefined && consumption!.consumptionNt !== undefined ? (
<div className="space-y-1">
<div><span className="text-sm text-gray-500">HT:</span> <span className="font-mono">{formatNumber(consumption!.consumptionHt)} kWh</span></div>
<div><span className="text-sm text-gray-500">NT:</span> <span className="font-mono">{formatNumber(consumption!.consumptionNt)} kWh</span></div>
<div className="text-sm text-gray-500">Gesamt: {formatNumber(consumption!.consumptionKwh)} kWh</div>
</div>
) : (
<span className="font-mono">{formatNumber(consumption.consumptionKwh)} kWh</span>
<span className="font-mono">{formatNumber(consumption!.consumptionKwh)} kWh</span>
)}
</div>
{consumption.startReading && consumption.endReading && (
{consumption!.startReading && consumption!.endReading && (
<p className="text-xs text-gray-400 mt-1">
Basierend auf Zählerständen vom {formatDate(consumption.startReading.readingDate)} bis {formatDate(consumption.endReading.readingDate)}
Basierend auf Zählerständen vom {formatDate(consumption!.startReading.readingDate)} bis {formatDate(consumption!.endReading.readingDate)}
</p>
)}
</div>
@@ -533,15 +752,24 @@ function EnergyConsumptionCalculation({
<span className="font-mono">{formatNumber(costs.annualBaseCost)} </span>
</div>
)}
{/* Arbeitspreis */}
{/* Arbeitspreis HT */}
{unitPrice != null && unitPrice > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">
Arbeitspreis: {formatNumber(consumption.consumptionKwh)} kWh × {formatNumber(unitPrice, 4)}
{costs.annualConsumptionCostNt ? 'HT-Arbeitspreis' : 'Arbeitspreis'}: {formatNumber(htKwh)} kWh × {formatNumber(unitPrice, 4)}
</span>
<span className="font-mono">{formatNumber(costs.annualConsumptionCost)} </span>
</div>
)}
{/* Arbeitspreis NT */}
{costs.annualConsumptionCostNt != null && costs.annualConsumptionCostNt > 0 && unitPriceNt && (
<div className="flex justify-between">
<span className="text-gray-600">
NT-Arbeitspreis: {formatNumber(ntKwh || 0)} kWh × {formatNumber(unitPriceNt, 4)}
</span>
<span className="font-mono">{formatNumber(costs.annualConsumptionCostNt)} </span>
</div>
)}
{/* Trennlinie */}
<div className="border-t border-gray-300 pt-2">
<div className="flex justify-between font-medium">
@@ -1212,7 +1440,7 @@ export default function ContractDetail() {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const backTo = (location.state as any)?.from as string | undefined;
const currentPath = `/contracts/${id}`;
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!);
@@ -1424,13 +1652,15 @@ export default function ContractDetail() {
}
const c = data.data;
const fallbackBack = isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts');
const back = popHistory(location.state, fallbackBack);
// 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(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}>
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">Vertrag {c.contractNumber}</h1>
@@ -1465,7 +1695,7 @@ export default function ContractDetail() {
<Button
variant="ghost"
size="sm"
onClick={() => navigate(backTo || (isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts')))}
onClick={() => navigate(back.to, { state: back.state })}
>
<ArrowLeft className="w-4 h-4" />
</Button>
@@ -1499,7 +1729,7 @@ export default function ContractDetail() {
{c.customer && (
<p className="text-gray-500 ml-10">
Kunde:{' '}
<Link to={`/customers/${c.customer.id}`} state={{ from: `/contracts/${id}` }} className="text-blue-600 hover:underline">
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</Link>
</p>
@@ -1534,7 +1764,7 @@ export default function ContractDetail() {
</Link>
)}
{hasPermission('contracts:update') && (
<Link to={`/contracts/${id}/edit`} state={{ from: `/contracts/${id}` }}>
<Link to={`/contracts/${id}/edit`} state={pushHistory(currentPath, location.state)}>
<Button variant="secondary">
<Edit className="w-4 h-4 mr-2" />
Bearbeiten
@@ -2201,7 +2431,12 @@ export default function ContractDetail() {
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.energyDetails.meter && (
<div>
<dt className="text-sm text-gray-500">Zählernummer</dt>
<dt className="text-sm text-gray-500 flex items-center gap-1">
Zählernummer
{c.energyDetails.meter.tariffModel === 'DUAL' && (
<Badge variant="default">HT/NT</Badge>
)}
</dt>
<dd className="font-mono flex items-center gap-1">
{c.energyDetails.meter.meterNumber}
<CopyButton value={c.energyDetails.meter.meterNumber} />
@@ -2242,12 +2477,22 @@ export default function ContractDetail() {
)}
{c.energyDetails.unitPrice != null && (
<div>
<dt className="text-sm text-gray-500">Arbeitspreis</dt>
<dt className="text-sm text-gray-500">
{c.energyDetails.unitPriceNt != null ? 'HT-Arbeitspreis' : 'Arbeitspreis'}
</dt>
<dd>
{c.energyDetails.unitPrice.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh
</dd>
</div>
)}
{c.energyDetails.unitPriceNt != null && (
<div>
<dt className="text-sm text-gray-500">NT-Arbeitspreis</dt>
<dd>
{c.energyDetails.unitPriceNt.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 10 })} /kWh
</dd>
</div>
)}
{c.energyDetails.bonus && (
<div>
<dt className="text-sm text-gray-500">Bonus</dt>
@@ -2271,29 +2516,58 @@ export default function ContractDetail() {
)}
</dl>
{/* Zählerstände */}
{c.energyDetails.meter && (
{/* Zählerstände - Multi-Zähler Support */}
{c.energyDetails.contractMeters && c.energyDetails.contractMeters.length > 0 ? (
<>
{c.energyDetails.contractMeters.map((cm: ContractMeter, idx: number) => (
cm.meter && (
<MeterReadingsSection
key={cm.id}
meterId={cm.meter.id}
meterType={cm.meter.type}
tariffModel={cm.meter.tariffModel}
readings={cm.meter.readings || []}
canEdit={hasPermission('contracts:update') && !isCustomer}
label={c.energyDetails!.contractMeters!.length > 1
? `Zähler ${idx + 1}: ${cm.meter.meterNumber}${cm.removedAt ? ' (gewechselt)' : ' (aktuell)'}`
: undefined
}
/>
)
))}
{/* Folgezähler hinzufügen */}
{hasPermission('contracts:update') && !isCustomer && (
<SuccessorMeterButton
contractId={contractId}
customerId={c.customerId}
meterType={c.type as 'ELECTRICITY' | 'GAS'}
existingMeterIds={c.energyDetails.contractMeters.map((cm: ContractMeter) => cm.meterId)}
/>
)}
</>
) : c.energyDetails.meter ? (
<MeterReadingsSection
meterId={c.energyDetails.meter.id}
meterType={c.energyDetails.meter.type}
tariffModel={c.energyDetails.meter.tariffModel}
readings={c.energyDetails.meter.readings || []}
contractId={contractId}
canEdit={hasPermission('contracts:update') && !isCustomer}
/>
)}
) : null}
{/* Verbrauchsberechnung & Kostenvorschau */}
{c.energyDetails.meter && c.startDate && c.endDate && (
<EnergyConsumptionCalculation
contractType={c.type as 'ELECTRICITY' | 'GAS'}
readings={c.energyDetails.meter.readings || []}
startDate={c.startDate}
endDate={c.endDate}
basePrice={c.energyDetails.basePrice}
unitPrice={c.energyDetails.unitPrice}
bonus={c.energyDetails.bonus}
/>
)}
<EnergyConsumptionCalculation
contractType={c.type as 'ELECTRICITY' | 'GAS'}
readings={c.energyDetails.meter?.readings || []}
startDate={c.startDate}
endDate={c.endDate}
basePrice={c.energyDetails.basePrice}
unitPrice={c.energyDetails.unitPrice}
unitPriceNt={c.energyDetails.unitPriceNt}
bonus={c.energyDetails.bonus}
hasMeter={!!c.energyDetails.meter}
contractMeters={c.energyDetails.contractMeters}
/>
{/* Rechnungen */}
<InvoicesSection
+20 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
import { popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api';
@@ -64,7 +65,7 @@ export default function ContractForm() {
const location = useLocation();
const queryClient = useQueryClient();
const isEdit = !!id;
const backTo = (location.state as any)?.from as string | undefined;
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
const preselectedCustomerId = searchParams.get('customerId');
@@ -80,6 +81,7 @@ export default function ContractForm() {
const contractType = watch('type') as ContractType;
const customerId = watch('customerId');
const previousContractId = watch('previousContractId');
const selectedMeterId = watch('meterId');
// Fetch existing contract for edit
const { data: contract } = useQuery({
@@ -276,6 +278,7 @@ export default function ContractForm() {
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
basePrice: c.energyDetails?.basePrice || '',
unitPrice: c.energyDetails?.unitPrice || '',
unitPriceNt: c.energyDetails?.unitPriceNt || '',
bonus: c.energyDetails?.bonus || '',
// Internet details
downloadSpeed: c.internetDetails?.downloadSpeed || '',
@@ -512,6 +515,7 @@ export default function ContractForm() {
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
unitPrice: data.unitPrice ? parseFloat(data.unitPrice) : null,
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
bonus: data.bonus ? parseFloat(data.bonus) : null,
};
}
@@ -605,7 +609,8 @@ export default function ContractForm() {
const addresses = customer?.addresses || [];
const bankCards = customer?.bankCards?.filter((c) => c.isActive) || [];
const documents = customer?.identityDocuments?.filter((d) => d.isActive) || [];
const meters = customer?.meters?.filter((m) => m.isActive) || [];
const meters = customer?.meters || [];
const selectedMeter = meters.find(m => m.id.toString() === selectedMeterId);
const stressfreiEmails = customer?.stressfreiEmails?.filter((e: { isActive: boolean }) => e.isActive) || [];
const platforms = platformsData?.data || [];
const cancellationPeriods = cancellationPeriodsData?.data || [];
@@ -658,7 +663,7 @@ export default function ContractForm() {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isEdit ? `/contracts/${id}` : '/contracts'))}>
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">
@@ -951,10 +956,10 @@ export default function ContractForm() {
label="Zähler"
{...register('meterId')}
options={meters
.filter((m) => m.type === contractType)
.filter((m) => m.type === contractType && (m.isActive || m.id.toString() === watch('meterId')))
.map((m) => ({
value: m.id,
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}`,
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
}))}
/>
<Input
@@ -975,11 +980,19 @@ export default function ContractForm() {
)}
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
<Input
label="Arbeitspreis (€/kWh)"
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
type="number"
step="any"
{...register('unitPrice')}
/>
{selectedMeter?.tariffModel === 'DUAL' && (
<Input
label="NT-Arbeitspreis (€/kWh)"
type="number"
step="any"
{...register('unitPriceNt')}
/>
)}
<Input label="Bonus (€)" type="number" step="0.01" {...register('bonus')} />
</div>
@@ -1409,7 +1422,7 @@ export default function ContractForm() {
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(backTo || (isEdit ? `/contracts/${id}` : '/contracts'))}>
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { contractApi, ContractTreeNode } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
@@ -271,7 +272,7 @@ export default function ContractList() {
<div className="w-6" /> // Platzhalter für Ausrichtung
) : null}
<Link to={`/contracts/${contract.id}`} state={{ from: '/contracts' }} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="font-mono flex items-center gap-1 hover:text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</Link>
@@ -456,7 +457,7 @@ export default function ContractList() {
{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">
<Link to={`/contracts/${contract.id}`} state={{ from: '/contracts' }} className="text-blue-600 hover:underline">
<Link to={`/contracts/${contract.id}`} state={pushHistory('/contracts')} className="text-blue-600 hover:underline">
{contract.contractNumber}
</Link>
</td>
@@ -465,7 +466,7 @@ export default function ContractList() {
{contract.customer && (
<Link
to={`/customers/${contract.customer.id}`}
state={{ from: '/contracts' }}
state={pushHistory('/contracts')}
className="text-blue-600 hover:underline"
>
{contract.customer.companyName ||
@@ -505,7 +506,7 @@ export default function ContractList() {
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && !isCustomer && (
<Link to={`/contracts/${contract.id}/edit`} state={{ from: '/contracts' }}>
<Link to={`/contracts/${contract.id}/edit`} state={pushHistory('/contracts')}>
<Button variant="ghost" size="sm">
<Edit className="w-4 h-4" />
</Button>