Datenschutz vollmacht fixed, two time counter added
This commit is contained in:
@@ -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'] });
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user