Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter
- Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration 20260530100000_meter_address mit IF NOT EXISTS - Service erzwingt beim Create: Lieferadresse vorhanden + zum Kunden gehörig + Typ DELIVERY_RESIDENCE - MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen "nicht zugeordnet – bitte über Bearbeiten nachpflegen" - ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse; deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei Auswahl Toast-Warnung wegen möglichem Altvertrag Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams, useLocation } from 'react-rout
|
||||
import { popHistory } from '../../utils/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -964,16 +965,47 @@ export default function ContractForm() {
|
||||
{['ELECTRICITY', 'GAS'].includes(contractType) && (
|
||||
<Card className="mb-6" title={contractType === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Zähler"
|
||||
{...register('meterId')}
|
||||
options={meters
|
||||
.filter((m) => m.type === contractType && (m.isActive || m.id.toString() === watch('meterId')))
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
|
||||
}))}
|
||||
/>
|
||||
{(() => {
|
||||
const selectedAddressId = watch('addressId');
|
||||
const meterRegister = register('meterId');
|
||||
// Zähler werden auf die Lieferadresse des Vertrags gefiltert. Zähler ohne
|
||||
// Lieferadresse (Bestand) bleiben für den aktuell gewählten Eintrag sichtbar,
|
||||
// damit die Auswahl nicht verschwindet, bis sie nachgepflegt sind.
|
||||
const filteredMeters = meters.filter((m) => {
|
||||
if (m.type !== contractType) return false;
|
||||
const isCurrentlySelected = m.id.toString() === watch('meterId');
|
||||
if (isCurrentlySelected) return true;
|
||||
if (!selectedAddressId) return false;
|
||||
return m.addressId != null && m.addressId.toString() === selectedAddressId;
|
||||
});
|
||||
return (
|
||||
<Select
|
||||
label="Zähler"
|
||||
{...meterRegister}
|
||||
onChange={(e) => {
|
||||
meterRegister.onChange(e);
|
||||
const m = meters.find((x) => x.id.toString() === e.target.value);
|
||||
if (m && !m.isActive) {
|
||||
toast(
|
||||
'Deaktivierter Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen Altvertrag?',
|
||||
{ icon: '⚠️', duration: 6000 },
|
||||
);
|
||||
}
|
||||
}}
|
||||
options={filteredMeters.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
|
||||
}))}
|
||||
placeholder={
|
||||
!selectedAddressId
|
||||
? 'Erst Lieferadresse wählen...'
|
||||
: filteredMeters.length === 0
|
||||
? 'Kein Zähler für diese Adresse vorhanden'
|
||||
: 'Zähler wählen...'
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<Input
|
||||
label="MaLo-ID (Marktlokations-ID)"
|
||||
{...register('maloId')}
|
||||
|
||||
@@ -142,6 +142,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
<MetersTab
|
||||
customerId={customerId}
|
||||
meters={c.meters || []}
|
||||
addresses={c.addresses || []}
|
||||
canEdit={hasPermission('customers:update')}
|
||||
showInactive={showInactive}
|
||||
onToggleInactive={() => setShowInactive(!showInactive)}
|
||||
@@ -455,6 +456,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
isOpen={showMeterModal}
|
||||
onClose={() => setShowMeterModal(false)}
|
||||
customerId={customerId}
|
||||
addresses={c.addresses || []}
|
||||
/>
|
||||
|
||||
<MeterModal
|
||||
@@ -462,6 +464,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
onClose={() => setEditingMeter(null)}
|
||||
customerId={customerId}
|
||||
meter={editingMeter}
|
||||
addresses={c.addresses || []}
|
||||
/>
|
||||
|
||||
<StressfreiEmailModal
|
||||
@@ -1243,6 +1246,7 @@ function DocumentsTab({
|
||||
function MetersTab({
|
||||
customerId,
|
||||
meters,
|
||||
addresses,
|
||||
canEdit,
|
||||
showInactive,
|
||||
onToggleInactive,
|
||||
@@ -1251,12 +1255,14 @@ function MetersTab({
|
||||
}: {
|
||||
customerId: number;
|
||||
meters: Meter[];
|
||||
addresses: Address[];
|
||||
canEdit: boolean;
|
||||
showInactive: boolean;
|
||||
onToggleInactive: () => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (meter: Meter) => void;
|
||||
}) {
|
||||
const hasDeliveryAddress = addresses.some((a) => a.type === 'DELIVERY_RESIDENCE');
|
||||
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'; tariffModel?: string; reading: any } | null>(null);
|
||||
@@ -1301,7 +1307,12 @@ function MetersTab({
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{canEdit && (
|
||||
<Button size="sm" onClick={onAdd}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={!hasDeliveryAddress}
|
||||
title={hasDeliveryAddress ? undefined : 'Erst Lieferadresse anlegen, dann Zähler hinzufügen'}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Zähler hinzufügen
|
||||
</Button>
|
||||
@@ -1316,6 +1327,12 @@ function MetersTab({
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
</div>
|
||||
{canEdit && !hasDeliveryAddress && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
|
||||
Es ist noch keine Lieferadresse hinterlegt. Bitte zunächst im Tab „Adressen" eine
|
||||
Lieferadresse anlegen, dann können hier Zähler erstellt werden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
@@ -1405,6 +1422,16 @@ function MetersTab({
|
||||
{meter.meterNumber}
|
||||
<CopyButton value={meter.meterNumber} />
|
||||
</p>
|
||||
{meter.address ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
Lieferadresse: {meter.address.street} {meter.address.houseNumber},{' '}
|
||||
{meter.address.postalCode} {meter.address.city}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-yellow-700">
|
||||
Lieferadresse: <em>nicht zugeordnet – bitte über „Bearbeiten" nachpflegen</em>
|
||||
</p>
|
||||
)}
|
||||
{meter.location && (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1">
|
||||
Standort: {meter.location}
|
||||
@@ -2799,14 +2826,17 @@ function MeterModal({
|
||||
onClose,
|
||||
customerId,
|
||||
meter,
|
||||
addresses,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customerId: number;
|
||||
meter?: Meter | null;
|
||||
addresses: Address[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!meter;
|
||||
const deliveryAddresses = addresses.filter((a) => a.type === 'DELIVERY_RESIDENCE');
|
||||
|
||||
const getInitialFormData = () => ({
|
||||
meterNumber: meter?.meterNumber || '',
|
||||
@@ -2814,17 +2844,20 @@ function MeterModal({
|
||||
tariffModel: meter?.tariffModel || 'SINGLE' as const,
|
||||
location: meter?.location || '',
|
||||
isActive: meter?.isActive ?? true,
|
||||
addressId: meter?.addressId?.toString() || '',
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState(getInitialFormData);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => meterApi.create(customerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true });
|
||||
setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true, addressId: '' });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : 'Fehler beim Speichern'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -2833,14 +2866,28 @@ function MeterModal({
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : 'Fehler beim Speichern'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!formData.addressId) {
|
||||
setError('Bitte eine Lieferadresse auswählen');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
meterNumber: formData.meterNumber,
|
||||
type: formData.type,
|
||||
tariffModel: formData.tariffModel,
|
||||
location: formData.location,
|
||||
isActive: formData.isActive,
|
||||
addressId: parseInt(formData.addressId),
|
||||
};
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(formData);
|
||||
updateMutation.mutate(payload);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2851,9 +2898,30 @@ function MeterModal({
|
||||
setFormData(getInitialFormData());
|
||||
}
|
||||
|
||||
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zähler bearbeiten' : 'Zähler hinzufügen'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{noDeliveryAddresses && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
|
||||
<strong>Keine Lieferadresse vorhanden.</strong> Bitte zunächst im Tab „Adressen" eine
|
||||
Lieferadresse anlegen, bevor ein Zähler erstellt werden kann.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Lieferadresse"
|
||||
value={formData.addressId}
|
||||
onChange={(e) => setFormData({ ...formData, addressId: e.target.value })}
|
||||
options={deliveryAddresses.map((a) => ({
|
||||
value: a.id,
|
||||
label: `${a.street} ${a.houseNumber}, ${a.postalCode} ${a.city}`,
|
||||
}))}
|
||||
placeholder="Lieferadresse wählen..."
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Zählernummer"
|
||||
value={formData.meterNumber}
|
||||
@@ -2902,11 +2970,17 @@ function MeterModal({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{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
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Button type="submit" disabled={isPending || (!isEditing && noDeliveryAddresses)}>
|
||||
{isPending ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -216,6 +216,10 @@ export interface Meter {
|
||||
tariffModel: MeterTariffModel;
|
||||
location?: string;
|
||||
isActive: boolean;
|
||||
// Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler
|
||||
// (vor Einführung dieses Feldes) noch ohne Adresse existieren können.
|
||||
addressId?: number | null;
|
||||
address?: Address;
|
||||
readings?: MeterReading[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user