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>
+152 -51
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email';
@@ -22,7 +23,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
const queryClient = useQueryClient();
const { hasPermission, isCustomerPortal } = useAuth();
const location = useLocation();
const backTo = (location.state as any)?.from as string | undefined;
const back = popHistory(location.state, isCustomerPortal ? '/' : '/customers');
const [searchParams, setSearchParams] = useSearchParams();
const customerId = portalCustomerId || parseInt(id!);
const defaultTab = searchParams.get('tab') || 'addresses';
@@ -204,7 +205,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate(backTo || (isCustomerPortal ? '/' : '/customers'))}>
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
@@ -221,7 +222,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
</div>
<div className="flex gap-2">
{hasPermission('customers:update') && (
<Link to={`/customers/${id}/edit`} state={{ from: `/customers/${id}` }}>
<Link to={`/customers/${id}/edit`} state={pushHistory(location.pathname + location.search, (location as any).state)}>
<Button variant="secondary">
<Edit className="w-4 h-4 mr-2" />
Bearbeiten
@@ -649,7 +650,7 @@ function AddressesTab({
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: addressApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
return (
@@ -746,18 +747,18 @@ function BankCardsTab({
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BankCard> }) =>
bankCardApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: bankCardApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const handleDocumentUpload = async (cardId: number, file: File) => {
try {
await uploadApi.uploadBankCardDocument(cardId, file);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
@@ -768,7 +769,7 @@ function BankCardsTab({
if (!confirm('Dokument wirklich löschen?')) return;
try {
await uploadApi.deleteBankCardDocument(cardId);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
@@ -972,18 +973,18 @@ function DocumentsTab({
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IdentityDocument> }) =>
documentApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: documentApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const handleDocumentUpload = async (docId: number, file: File) => {
try {
await uploadApi.uploadIdentityDocument(docId, file);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
@@ -994,7 +995,7 @@ function DocumentsTab({
if (!confirm('Dokument wirklich löschen?')) return;
try {
await uploadApi.deleteIdentityDocument(docId);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
@@ -1204,26 +1205,34 @@ function MetersTab({
onAdd: () => void;
onEdit: (meter: Meter) => void;
}) {
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS' } | null>(null);
const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string } | null>(null);
const [expandedMeter, setExpandedMeter] = useState<number | null>(null);
const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; reading: any } | null>(null);
const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string; reading: any } | null>(null);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Meter> }) =>
meterApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMutation = useMutation({
mutationFn: meterApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setDeleteError(null);
},
onError: (err) => {
setDeleteError(err instanceof Error ? err.message : 'Fehler beim Löschen');
},
});
const deleteReadingMutation = useMutation({
mutationFn: ({ meterId, readingId }: { meterId: number; readingId: number }) =>
meterApi.deleteReading(meterId, readingId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const filtered = showInactive ? meters : meters.filter((m) => m.isActive);
@@ -1273,6 +1282,9 @@ function MetersTab({
<Badge variant={meter.type === 'ELECTRICITY' ? 'warning' : 'info'}>
{meter.type === 'ELECTRICITY' ? 'Strom' : 'Gas'}
</Badge>
{meter.tariffModel === 'DUAL' && (
<Badge variant="default">HT/NT</Badge>
)}
{!meter.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{canEdit && (
@@ -1281,7 +1293,7 @@ function MetersTab({
<Button
variant="ghost"
size="sm"
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type })}
onClick={() => setShowReadingModal({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel })}
title="Zählerstand hinzufügen"
>
<Plus className="w-4 h-4" />
@@ -1371,14 +1383,17 @@ function MetersTab({
</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" />
<CopyButton value={`${reading.value.toLocaleString('de-DE')} ${reading.unit}`} title="Mit Einheit kopieren" />
{reading.valueNt !== undefined && 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">
<button
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, reading })}
onClick={() => setEditingReading({ meterId: meter.id, meterType: meter.type, tariffModel: meter.tariffModel, reading })}
className="text-gray-400 hover:text-blue-600"
title="Bearbeiten"
>
@@ -1417,6 +1432,7 @@ function MetersTab({
onClose={() => setShowReadingModal(null)}
meterId={showReadingModal.meterId}
meterType={showReadingModal.meterType}
tariffModel={showReadingModal.tariffModel as any}
customerId={customerId}
/>
)}
@@ -1427,10 +1443,43 @@ function MetersTab({
onClose={() => setEditingReading(null)}
meterId={editingReading.meterId}
meterType={editingReading.meterType}
tariffModel={editingReading.tariffModel as any}
customerId={customerId}
reading={editingReading.reading}
/>
)}
{/* Fehler-Modal beim Löschen (z.B. Zähler noch an Vertrag) */}
{deleteError && (
<Modal isOpen={true} onClose={() => setDeleteError(null)} title="Zähler kann nicht gelöscht werden">
<p className="text-sm text-gray-600 mb-4">
Der Zähler ist noch folgenden Verträgen zugeordnet und kann daher nicht gelöscht werden:
</p>
<div className="space-y-2">
{deleteError.match(/[A-Z]+-[A-Z0-9]+/g)?.map((contractNumber) => (
<Link
key={contractNumber}
to={`/contracts?search=${contractNumber}`}
onClick={() => setDeleteError(null)}
className="flex items-center gap-2 p-3 bg-gray-50 border rounded-lg text-blue-600 hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<FileText className="w-4 h-4" />
<span className="font-mono">{contractNumber}</span>
</Link>
)) ?? (
<p className="text-sm text-red-600">{deleteError}</p>
)}
</div>
<p className="text-xs text-gray-500 mt-4">
Bitte entfernen Sie den Zähler zuerst aus den oben genannten Verträgen.
</p>
<div className="flex justify-end mt-4">
<Button variant="secondary" onClick={() => setDeleteError(null)}>
Schließen
</Button>
</div>
</Modal>
)}
</div>
);
}
@@ -1457,7 +1506,7 @@ function ContractsTab({
const deleteMutation = useMutation({
mutationFn: contractApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['contract-tree', customerId] });
@@ -1549,7 +1598,7 @@ function ContractsTab({
<div className="w-6" /> // Platzhalter für Ausrichtung
) : null}
<Link to={`/contracts/${contract.id}`} state={{ from: `/customers/${customerId}?tab=contracts` }} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
<Link to={`/contracts/${contract.id}`} state={pushHistory(location.pathname + location.search, (location as any).state)} className="font-mono flex items-center gap-1 text-blue-600 hover:underline">
{contract.contractNumber}
<CopyButton value={contract.contractNumber} />
</Link>
@@ -2054,7 +2103,7 @@ function AddressModal({
const createMutation = useMutation({
mutationFn: (data: typeof formData) => addressApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({
type: 'DELIVERY_RESIDENCE',
@@ -2071,7 +2120,7 @@ function AddressModal({
const updateMutation = useMutation({
mutationFn: (data: typeof formData) => addressApi.update(address!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@@ -2201,7 +2250,7 @@ function BankCardModal({
const createMutation = useMutation({
mutationFn: (data: any) => bankCardApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({ accountHolder: '', iban: '', bic: '', bankName: '', expiryDate: '', isActive: true });
},
@@ -2210,7 +2259,7 @@ function BankCardModal({
const updateMutation = useMutation({
mutationFn: (data: any) => bankCardApi.update(bankCard!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@@ -2333,7 +2382,7 @@ function DocumentModal({
const createMutation = useMutation({
mutationFn: (data: any) => documentApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({
type: 'ID_CARD',
@@ -2351,7 +2400,7 @@ function DocumentModal({
const updateMutation = useMutation({
mutationFn: (data: any) => documentApi.update(document!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@@ -2498,6 +2547,7 @@ function MeterModal({
const getInitialFormData = () => ({
meterNumber: meter?.meterNumber || '',
type: meter?.type || 'ELECTRICITY' as const,
tariffModel: meter?.tariffModel || 'SINGLE' as const,
location: meter?.location || '',
isActive: meter?.isActive ?? true,
});
@@ -2507,16 +2557,16 @@ function MeterModal({
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.create(customerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({ meterNumber: '', type: 'ELECTRICITY', location: '', isActive: true });
setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.update(meter!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@@ -2557,6 +2607,18 @@ function MeterModal({
]}
/>
{formData.type === 'ELECTRICITY' && (
<Select
label="Tarifmodell"
value={formData.tariffModel}
onChange={(e) => setFormData({ ...formData, tariffModel: e.target.value as any })}
options={[
{ value: 'SINGLE', label: 'Eintarifzähler (Standard)' },
{ value: 'DUAL', label: 'Zweitarifzähler (HT/NT)' },
]}
/>
)}
<Input
label="Standort"
value={formData.location}
@@ -2594,6 +2656,7 @@ function MeterReadingModal({
onClose,
meterId,
meterType,
tariffModel,
customerId,
reading,
}: {
@@ -2601,47 +2664,63 @@ function MeterReadingModal({
onClose: () => void;
meterId: number;
meterType: 'ELECTRICITY' | 'GAS';
tariffModel?: 'SINGLE' | 'DUAL';
customerId: number;
reading?: { id: number; readingDate: string; value: number; unit: string; notes?: string } | null;
reading?: { id: number; readingDate: string; value: number; valueNt?: number; unit: string; notes?: string } | null;
}) {
const queryClient = useQueryClient();
const isEditing = !!reading;
const defaultUnit = meterType === 'ELECTRICITY' ? 'kWh' : 'm³';
const isDualTariff = tariffModel === 'DUAL';
const getInitialFormData = () => ({
readingDate: reading?.readingDate
? 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 [formData, setFormData] = useState(getInitialFormData);
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: any) => meterApi.addReading(meterId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
setError(null);
onClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => meterApi.updateReading(meterId, reading!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
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);
} else {
@@ -2667,10 +2746,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}
@@ -2678,12 +2757,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
@@ -2693,6 +2786,12 @@ function MeterReadingModal({
placeholder="Optionale Notizen..."
/>
{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">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
@@ -2732,12 +2831,12 @@ function StressfreiEmailsTab({
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
stressfreiEmailApi.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const deleteMutation = useMutation({
mutationFn: stressfreiEmailApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }),
});
const filtered = showInactive ? emails : emails.filter((e) => e.isActive);
@@ -3087,7 +3186,7 @@ function StressfreiEmailModal({
const result = await stressfreiEmailApi.enableMailbox(email.id);
if (result.success) {
setMailboxEnabled(true);
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
} else {
setProvisionError(result.error || 'Mailbox-Aktivierung fehlgeschlagen');
@@ -3108,7 +3207,7 @@ function StressfreiEmailModal({
setMailboxEnabled(result.data.hasMailbox);
if (result.data.wasUpdated) {
// DB wurde aktualisiert, Query invalidieren
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
}
}
} catch (error) {
@@ -3199,7 +3298,7 @@ function StressfreiEmailModal({
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
setLocalPart('');
setNotes('');
@@ -3216,7 +3315,7 @@ function StressfreiEmailModal({
mutationFn: (data: Partial<StressfreiEmail>) =>
stressfreiEmailApi.update(email!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId.toString()] });
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
});
@@ -3664,6 +3763,7 @@ function ConsentTab({
try {
await uploadApi.uploadPrivacyPolicy(customerId, file);
onUpdate?.();
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
} catch (error) {
console.error('Upload fehlgeschlagen:', error);
alert('Upload fehlgeschlagen');
@@ -3675,6 +3775,7 @@ function ConsentTab({
try {
await uploadApi.deletePrivacyPolicy(customerId);
onUpdate?.();
queryClient.invalidateQueries({ queryKey: ['customer-consents', customerId] });
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen');
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useNavigate, useParams, 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 { customerApi } from '../../services/api';
@@ -17,7 +18,7 @@ export default function CustomerForm() {
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 ? `/customers/${id}` : '/customers');
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CustomerFormData>();
const customerType = watch('type');
@@ -234,7 +235,7 @@ export default function CustomerForm() {
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(backTo || (isEdit ? `/customers/${id}` : '/customers'))}>
<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 } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { pushHistory } from '../../utils/navigation';
import { customerApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
@@ -78,12 +79,12 @@ export default function CustomerList() {
{data.data.map((customer) => (
<tr key={customer.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">
<Link to={`/customers/${customer.id}`} state={{ from: '/customers' }} className="text-blue-600 hover:underline">
<Link to={`/customers/${customer.id}`} state={pushHistory('/customers')} className="text-blue-600 hover:underline">
{customer.customerNumber}
</Link>
</td>
<td className="py-3 px-4">
<Link to={`/customers/${customer.id}`} state={{ from: '/customers' }} className="text-blue-600 hover:underline">
<Link to={`/customers/${customer.id}`} state={pushHistory('/customers')} className="text-blue-600 hover:underline">
{customer.type === 'BUSINESS' && customer.companyName
? customer.companyName
: `${customer.firstName} ${customer.lastName}`}
+12 -6
View File
@@ -36,10 +36,15 @@ const RESOURCE_OPTIONS = [
{ value: 'Contract', label: 'Verträge' },
{ value: 'User', label: 'Benutzer' },
{ value: 'BankCard', label: 'Bankdaten' },
{ value: 'IdentityDocument', label: 'Ausweisdokumente' },
{ value: 'Authentication', label: 'Authentifizierung' },
{ value: 'IdentityDocument', label: 'Ausweise' },
{ value: 'Address', label: 'Adressen' },
{ value: 'Meter', label: 'Zähler' },
{ value: 'ContractTask', label: 'Aufgaben' },
{ value: 'Authentication', label: 'Anmeldung' },
{ value: 'CustomerConsent', label: 'Einwilligungen' },
{ value: 'GDPR', label: 'DSGVO' },
{ value: 'GDPR', label: 'DSGVO / Vollmacht' },
{ value: 'EmailProviderConfig', label: 'E-Mail-Provider' },
{ value: 'AppSetting', label: 'Einstellungen' },
];
function formatDate(date: string): string {
@@ -354,11 +359,12 @@ export default function AuditLogs() {
</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 className="text-sm truncate max-w-[300px]" title={log.resourceLabel}>
{log.resourceLabel}
</div>
) : (
<div className="text-sm text-gray-500">{log.resourceType}</div>
)}
</td>
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>
+9
View File
@@ -655,6 +655,15 @@ export const contractApi = {
const res = await api.get<ApiResponse<import('../types').CockpitResult>>('/contracts/cockpit');
return res.data;
},
// Folgezähler
addSuccessorMeter: async (contractId: number, data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) => {
const res = await api.post<ApiResponse<any>>(`/contracts/${contractId}/successor-meter`, data);
return res.data;
},
removeContractMeter: async (contractId: number, contractMeterId: number) => {
const res = await api.delete<ApiResponse<void>>(`/contracts/${contractId}/contract-meter/${contractMeterId}`);
return res.data;
},
// Snooze: Vertrag zurückstellen
snooze: async (id: number, data: { nextReviewDate?: string; months?: number }) => {
const res = await api.patch<ApiResponse<{ id: number; contractNumber: string; nextReviewDate: string | null }>>(`/contracts/${id}/snooze`, data);
+19 -1
View File
@@ -152,16 +152,30 @@ export interface IdentityDocument {
licenseIssueDate?: string;
}
export type MeterTariffModel = 'SINGLE' | 'DUAL';
export interface Meter {
id: number;
customerId: number;
meterNumber: string;
type: 'ELECTRICITY' | 'GAS';
tariffModel: MeterTariffModel;
location?: string;
isActive: boolean;
readings?: MeterReading[];
}
export interface ContractMeter {
id: number;
energyContractDetailsId: number;
meterId: number;
meter?: Meter;
position: number;
installedAt?: string;
removedAt?: string;
finalReading?: number;
}
export type MeterReadingStatus = 'RECORDED' | 'REPORTED' | 'TRANSFERRED';
export interface MeterReading {
@@ -169,6 +183,7 @@ export interface MeterReading {
meterId: number;
readingDate: string;
value: number;
valueNt?: number; // NT-Wert (nur bei Zweitarifzähler)
unit: string;
notes?: string;
reportedBy?: string;
@@ -396,11 +411,13 @@ export interface EnergyContractDetails {
annualConsumption?: number; // kWh für Strom, m³ für Gas
annualConsumptionKwh?: number; // kWh für Gas (zusätzlich zu m³)
basePrice?: number; // €/Monat
unitPrice?: number; // €/kWh (Arbeitspreis)
unitPrice?: number; // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
invoices?: Invoice[]; // Rechnungen
contractMeters?: ContractMeter[]; // Zähler-Zuordnungen (inkl. Folgezähler)
}
export interface InternetContractDetails {
@@ -575,6 +592,7 @@ export interface ReportedMeterReading {
createdAt: string;
meter: { id: number; meterNumber: string; type: string };
customer: { id: number; customerNumber: string; name: string };
contract?: { id: number; contractNumber: string };
providerPortal?: {
providerName: string;
portalUrl: string;
+112 -15
View File
@@ -1,4 +1,4 @@
import type { MeterReading } from '../types';
import type { MeterReading, ContractMeter } from '../types';
// Konstante für Gas → kWh Umrechnung (Standard Erdgas H)
export const GAS_TO_KWH_FACTOR = 10.5;
@@ -8,6 +8,8 @@ export interface ConsumptionCalculation {
type: 'exact' | 'projected' | 'insufficient' | 'none';
consumptionM3?: number; // Nur bei Gas
consumptionKwh: number;
consumptionHt?: number; // Nur bei HT/NT: HT-Verbrauch in kWh
consumptionNt?: number; // Nur bei HT/NT: NT-Verbrauch in kWh
startReading?: MeterReading;
endReading?: MeterReading;
projectedEndDate?: string; // Nur bei Hochrechnung
@@ -17,7 +19,8 @@ export interface ConsumptionCalculation {
// Ergebnis der Kostenberechnung
export interface CostCalculation {
annualBaseCost: number; // basePrice × 12
annualConsumptionCost: number; // verbrauch × unitPrice
annualConsumptionCost: number; // verbrauch × unitPrice (HT bei Zweitarif)
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
annualTotalCost: number; // Summe
monthlyPayment: number; // annualTotalCost / 12
bonus?: number;
@@ -94,7 +97,6 @@ export function calculateConsumption(
(a, b) => new Date(a.readingDate).getTime() - new Date(b.readingDate).getTime()
);
const firstReading = sorted[0];
const lastReading = sorted[sorted.length - 1];
// Prüfen ob Endzählerstand am/nach Vertragsende liegt
@@ -103,16 +105,39 @@ export function calculateConsumption(
lastReadingDate.setHours(0, 0, 0, 0);
contractEndDate.setHours(0, 0, 0, 0);
// Fall A: Exakter Verbrauch (Endzählerstand am oder nach Vertragsende)
// Verbrauch zwischen aufeinanderfolgenden Ständen berechnen
// (Erkennt Zählerwechsel: wenn ein Wert sinkt, wird ab dem neuen Stand weitergerechnet)
let totalConsumption = 0;
let totalConsumptionNt = 0;
const hasNt = sorted.some(r => r.valueNt !== undefined && r.valueNt !== null);
let effectiveFirstReading = sorted[0];
for (let i = 1; i < sorted.length; i++) {
const diff = sorted[i].value - sorted[i - 1].value;
if (diff < 0) {
totalConsumption = 0;
totalConsumptionNt = 0;
effectiveFirstReading = sorted[i];
} else {
totalConsumption += diff;
if (hasNt && sorted[i].valueNt != null && sorted[i - 1].valueNt != null) {
const diffNt = sorted[i].valueNt! - sorted[i - 1].valueNt!;
if (diffNt >= 0) totalConsumptionNt += diffNt;
}
}
}
const effectiveLastReading = sorted[sorted.length - 1];
// Fall A: Exakter Verbrauch
if (lastReadingDate >= contractEndDate) {
const consumption = lastReading.value - firstReading.value;
return formatConsumptionResult('exact', consumption, contractType, firstReading, lastReading);
const result = formatConsumptionResult('exact', totalConsumption, contractType, effectiveFirstReading, effectiveLastReading);
if (hasNt) { result.consumptionHt = totalConsumption; result.consumptionNt = totalConsumptionNt; }
return result;
}
// Fall B: Hochrechnung erforderlich
const daysBetweenReadings = daysDiff(firstReading.readingDate, lastReading.readingDate);
const daysBetweenReadings = daysDiff(effectiveFirstReading.readingDate, effectiveLastReading.readingDate);
// Mindestens 1 Tag zwischen den Messungen für sinnvolle Hochrechnung
if (daysBetweenReadings < 1) {
return {
type: 'insufficient',
@@ -122,17 +147,26 @@ export function calculateConsumption(
}
const totalContractDays = daysDiff(startDate, endDate);
const measuredConsumption = lastReading.value - firstReading.value;
const projectedConsumption = (measuredConsumption / daysBetweenReadings) * totalContractDays;
const projectedConsumption = (totalConsumption / daysBetweenReadings) * totalContractDays;
return formatConsumptionResult(
const result = formatConsumptionResult(
'projected',
projectedConsumption,
contractType,
firstReading,
lastReading,
effectiveFirstReading,
effectiveLastReading,
endDate
);
if (hasNt) {
const projectedNt = (totalConsumptionNt / daysBetweenReadings) * totalContractDays;
result.consumptionHt = projectedConsumption;
result.consumptionNt = projectedNt;
// Gesamt-kWh = HT + NT
result.consumptionKwh = projectedConsumption + projectedNt;
}
return result;
}
/**
@@ -173,7 +207,9 @@ export function calculateCosts(
consumptionKwh: number,
basePrice?: number,
unitPrice?: number,
bonus?: number
bonus?: number,
consumptionNtKwh?: number,
unitPriceNt?: number
): CostCalculation | null {
// Mindestens ein Preis muss vorhanden sein
if (basePrice == null && unitPrice == null) {
@@ -181,17 +217,78 @@ export function calculateCosts(
}
const annualBaseCost = (basePrice ?? 0) * 12;
// Bei HT/NT: consumptionKwh ist nur HT, NT wird separat berechnet
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
const annualTotalCost = annualBaseCost + annualConsumptionCost;
const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0);
const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt;
const effectiveAnnualCost = annualTotalCost - (bonus ?? 0);
const monthlyPayment = effectiveAnnualCost / 12;
return {
annualBaseCost,
annualConsumptionCost,
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
annualTotalCost,
monthlyPayment,
bonus: bonus ?? undefined,
effectiveAnnualCost,
};
}
/**
* Berechnet den Verbrauch über mehrere Zähler (Folgezähler).
* Pro Zähler wird der Verbrauch einzeln berechnet und dann summiert.
*/
export function calculateMultiMeterConsumption(
contractMeters: ContractMeter[],
startDate: string,
endDate: string,
contractType: 'ELECTRICITY' | 'GAS'
): ConsumptionCalculation {
if (contractMeters.length === 0) {
return { type: 'none', consumptionKwh: 0 };
}
let totalConsumption = 0;
let totalConsumptionM3 = 0;
let hasExact = true;
let hasAny = false;
let firstStart: MeterReading | undefined;
let lastEnd: MeterReading | undefined;
for (const cm of contractMeters) {
const readings = cm.meter?.readings || [];
if (readings.length === 0) continue;
// Zeitraum für diesen Zähler bestimmen
const meterStart = cm.installedAt || startDate;
const meterEnd = cm.removedAt || endDate;
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);
if (result.type === 'none' || result.type === 'insufficient') continue;
hasAny = true;
if (result.type === 'projected') hasExact = false;
totalConsumption += result.consumptionKwh;
if (result.consumptionM3) totalConsumptionM3 += result.consumptionM3;
if (!firstStart && result.startReading) firstStart = result.startReading;
if (result.endReading) lastEnd = result.endReading;
}
if (!hasAny) {
// Fallback: Einzelzähler-Berechnung mit allen Readings
const allReadings = contractMeters.flatMap(cm => cm.meter?.readings || []);
return calculateConsumption(allReadings, startDate, endDate, contractType);
}
return {
type: hasExact ? 'exact' : 'projected',
consumptionKwh: totalConsumption,
consumptionM3: contractType === 'GAS' ? totalConsumptionM3 : undefined,
startReading: firstStart,
endReading: lastEnd,
};
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Navigation-History über location.state.
* Jeder Link fügt die aktuelle URL zum Stack hinzu.
* Der Zurück-Button poppt den letzten Eintrag und gibt den Rest weiter.
*/
export interface NavState {
history?: string[];
}
/**
* Erstellt den state für einen Link - fügt die aktuelle URL zum History-Stack hinzu.
*/
export function pushHistory(currentPath: string, locationState?: unknown): NavState {
const prev = (locationState as NavState)?.history || [];
return { history: [...prev, currentPath] };
}
/**
* Gibt die Zurück-URL und den verbleibenden History-Stack zurück.
*/
export function popHistory(locationState?: unknown, fallback?: string): { to: string; state: NavState } {
const history = [...((locationState as NavState)?.history || [])];
const to = history.pop() || fallback || '/';
return { to, state: { history } };
}