Files
opencrm/frontend/src/pages/contracts/ContractForm.tsx
T
duffyduck 5269092d2a Fix: "Wurde sondergekündigt?"-Label nicht über volle Spaltenbreite klickbar
Label-Klasse war flex -> Block-Layout, das die ganze col-span-2-Zeile
einnimmt. Klicks rechts neben dem Text triggern dann ebenfalls die
Checkbox. Fix: inline-flex – die Label-Box passt sich an den Inhalt
(Checkbox + Text) an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:49:37 +02:00

1688 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, 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 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';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
import { calculateConsumption, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
// Contract types are now loaded dynamically from the database
const statusOptions = [
{ value: 'DRAFT', label: 'Entwurf' },
{ value: 'PENDING', label: 'Ausstehend' },
{ value: 'ACTIVE', label: 'Aktiv' },
{ value: 'CANCELLED', label: 'Gekündigt' },
{ value: 'EXPIRED', label: 'Abgelaufen' },
{ value: 'DEACTIVATED', label: 'Deaktiviert' },
];
// Status-Erklärungen für Info-Modal
const statusDescriptions = [
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
];
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
{statusDescriptions.map(({ status, label, description, color }) => (
<div key={status} className="flex items-start gap-2">
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
<span className="text-sm text-gray-600">{description}</span>
</div>
))}
</div>
</div>
</div>
);
}
export default function ContractForm() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const isEdit = !!id;
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
const { customerEmailLabel } = useProviderSettings();
const preselectedCustomerId = searchParams.get('customerId');
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<any>({
defaultValues: {
customerId: preselectedCustomerId || '',
type: 'ELECTRICITY',
status: 'DRAFT',
previousContractId: '',
},
});
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({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(parseInt(id!)),
enabled: isEdit,
});
// Fetch customers for dropdown
const { data: customersData } = useQuery({
queryKey: ['customers-all'],
queryFn: () => customerApi.getAll({ limit: 1000 }),
});
// Fetch customer details when selected
const { data: customerDetail } = useQuery({
queryKey: ['customer', customerId],
queryFn: () => customerApi.getById(parseInt(customerId)),
enabled: !!customerId,
});
// Fetch contracts for same customer (for predecessor selection)
const { data: customerContractsData } = useQuery({
queryKey: ['customer-contracts-for-predecessor', customerId],
queryFn: () => contractApi.getAll({ customerId: parseInt(customerId), limit: 1000 }),
enabled: !!customerId,
});
// Vorvertrag (für Verbrauchs-Übernahme bei Energieverträgen). Wird nur geladen,
// wenn ein previousContractId gesetzt ist und der Typ Strom/Gas ist.
const { data: previousContractData } = useQuery({
queryKey: ['contract', previousContractId],
queryFn: () => contractApi.getById(parseInt(previousContractId)),
enabled: !!previousContractId && ['ELECTRICITY', 'GAS'].includes(contractType),
});
// Fetch platforms
const { data: platformsData } = useQuery({
queryKey: ['platforms'],
queryFn: () => platformApi.getAll(),
});
// Fetch cancellation periods
const { data: cancellationPeriodsData } = useQuery({
queryKey: ['cancellation-periods'],
queryFn: () => cancellationPeriodApi.getAll(),
});
// Fetch contract durations
const { data: contractDurationsData } = useQuery({
queryKey: ['contract-durations'],
queryFn: () => contractDurationApi.getAll(),
});
// Fetch providers
const { data: providersData } = useQuery({
queryKey: ['providers'],
queryFn: () => providerApi.getAll(),
});
// Fetch contract categories (types)
const { data: contractCategoriesData } = useQuery({
queryKey: ['contract-categories'],
queryFn: () => contractCategoryApi.getAll(),
});
// Watch providerId for tariff filtering
const selectedProviderId = watch('providerId');
// Reset tariffId when providerId changes (but not on initial load)
const [previousProviderId, setPreviousProviderId] = useState<string | null>(null);
// SIM-Karten State für Mobilfunk
interface SimCardInput {
id?: number;
phoneNumber: string;
simCardNumber: string;
pin: string;
puk: string;
hasExistingPin?: boolean; // Zeigt an ob PIN bereits in DB vorhanden
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
isMultisim: boolean;
isMain: boolean;
}
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
// Rufnummern State für Internet-Verträge
interface PhoneNumberInput {
id?: number;
phoneNumber: string;
sipUsername: string;
sipPassword: string;
hasExistingSipPassword?: boolean;
sipServer: string;
isMain: boolean;
}
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberInput[]>([]);
const [isContractLoaded, setIsContractLoaded] = useState(false);
// Portal-Benutzername: Manuell oder Stressfrei-Adresse
const [usernameType, setUsernameType] = useState<'manual' | 'stressfrei'>('manual');
const [selectedStressfreiEmailId, setSelectedStressfreiEmailId] = useState<string>('');
// Passwort-Sichtbarkeit
const [showPortalPassword, setShowPortalPassword] = useState(false);
const [showInternetPassword, setShowInternetPassword] = useState(false);
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
const [showSimPuks, setShowSimPuks] = useState<Record<number, boolean>>({});
// Status-Info Modal
const [showStatusInfo, setShowStatusInfo] = useState(false);
// For new contracts, mark as "loaded" immediately so provider change detection works
useEffect(() => {
if (!isEdit) {
setIsContractLoaded(true);
}
}, [isEdit]);
// Set preselected customer from URL params (for new contracts)
useEffect(() => {
if (!isEdit && preselectedCustomerId && customersData?.data) {
// Only set if the customer exists in the list
const customerExists = customersData.data.some(c => c.id.toString() === preselectedCustomerId);
if (customerExists) {
setValue('customerId', preselectedCustomerId);
}
}
}, [isEdit, preselectedCustomerId, customersData, setValue]);
// Reset tariffId when providerId changes (but only after initial contract load)
useEffect(() => {
// Only reset tariff if:
// 1. Contract has been loaded (or we're creating new)
// 2. Provider actually changed (not empty/null -> value which happens on load)
// Note: previousProviderId can be '' (empty string) initially, so we need to check for truthy
if (isContractLoaded && previousProviderId && selectedProviderId !== previousProviderId) {
setValue('tariffId', '');
}
setPreviousProviderId(selectedProviderId);
}, [selectedProviderId, previousProviderId, setValue, isContractLoaded]);
// Set default type when categories are loaded (only for new contracts)
useEffect(() => {
if (!isEdit && contractCategoriesData?.data && contractCategoriesData.data.length > 0) {
const currentType = watch('type');
// Only set if current type is empty or doesn't exist in categories
const categories = contractCategoriesData.data.filter(c => c.isActive);
const typeExists = categories.some(c => c.code === currentType);
if (!currentType || !typeExists) {
const firstCategory = categories.sort((a, b) => a.sortOrder - b.sortOrder)[0];
if (firstCategory) {
setValue('type', firstCategory.code);
}
}
}
}, [contractCategoriesData, isEdit, setValue, watch]);
// Bootstrap: Set customerId first so customerDetail can load (needed for dropdowns)
useEffect(() => {
if (isEdit && contract?.data && !customerId) {
setValue('customerId', contract.data.customerId.toString());
}
}, [isEdit, contract, customerId, setValue]);
// Reset form when contract data AND ALL dropdown data are loaded
// This ensures dropdowns can properly display the selected values
// We need customerDetail for addresses/bankCards/documents and providersData for tariffs
useEffect(() => {
if (contract?.data && platformsData?.data && contractCategoriesData?.data && providersData?.data && customerDetail?.data) {
const c = contract.data;
reset({
customerId: c.customerId.toString(),
type: c.type,
status: c.status,
addressId: c.addressId?.toString() || '',
billingAddressId: c.billingAddressId?.toString() || '',
bankCardId: c.bankCardId?.toString() || '',
identityDocumentId: c.identityDocumentId?.toString() || '',
salesPlatformId: c.salesPlatformId?.toString() || '',
providerId: c.providerId?.toString() || '',
tariffId: c.tariffId?.toString() || '',
providerName: c.providerName || '',
tariffName: c.tariffName || '',
customerNumberAtProvider: c.customerNumberAtProvider || '',
contractNumberAtProvider: c.contractNumberAtProvider || '',
priceFirst12Months: c.priceFirst12Months || '',
priceFrom13Months: c.priceFrom13Months || '',
priceAfter24Months: c.priceAfter24Months || '',
startDate: c.startDate ? c.startDate.split('T')[0] : '',
endDate: c.endDate ? c.endDate.split('T')[0] : '',
cancellationPeriodId: c.cancellationPeriodId?.toString() || '',
contractDurationId: c.contractDurationId?.toString() || '',
commission: c.commission || '',
portalUsername: c.portalUsername || '',
notes: c.notes || '',
// Energy details
meterId: c.energyDetails?.meterId?.toString() || '',
maloId: c.energyDetails?.maloId || '',
annualConsumption: c.energyDetails?.annualConsumption || '',
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
basePrice: c.energyDetails?.basePrice || '',
unitPrice: c.energyDetails?.unitPrice || '',
unitPriceNt: c.energyDetails?.unitPriceNt || '',
instantBonus: c.energyDetails?.instantBonus || '',
newCustomerBonus: c.energyDetails?.newCustomerBonus || '',
// Internet details
downloadSpeed: c.internetDetails?.downloadSpeed || '',
uploadSpeed: c.internetDetails?.uploadSpeed || '',
routerModel: c.internetDetails?.routerModel || '',
routerSerialNumber: c.internetDetails?.routerSerialNumber || '',
installationDate: c.internetDetails?.installationDate ? c.internetDetails.installationDate.split('T')[0] : '',
internetUsername: c.internetDetails?.internetUsername || '',
propertyType: c.internetDetails?.propertyType || '',
propertyLocation: c.internetDetails?.propertyLocation || '',
connectionLocation: c.internetDetails?.connectionLocation || '',
homeId: c.internetDetails?.homeId || '',
activationCode: c.internetDetails?.activationCode || '',
// Mobile details
requiresMultisim: c.mobileDetails?.requiresMultisim || false,
dataVolume: c.mobileDetails?.dataVolume || '',
includedMinutes: c.mobileDetails?.includedMinutes || '',
includedSMS: c.mobileDetails?.includedSMS || '',
deviceModel: c.mobileDetails?.deviceModel || '',
deviceImei: c.mobileDetails?.deviceImei || '',
// Legacy
phoneNumber: c.mobileDetails?.phoneNumber || '',
simCardNumber: c.mobileDetails?.simCardNumber || '',
// TV details
receiverModel: c.tvDetails?.receiverModel || '',
smartcardNumber: c.tvDetails?.smartcardNumber || '',
tvPackage: c.tvDetails?.package || '',
// Car insurance details
licensePlate: c.carInsuranceDetails?.licensePlate || '',
hsn: c.carInsuranceDetails?.hsn || '',
tsn: c.carInsuranceDetails?.tsn || '',
vin: c.carInsuranceDetails?.vin || '',
vehicleType: c.carInsuranceDetails?.vehicleType || '',
firstRegistration: c.carInsuranceDetails?.firstRegistration ? c.carInsuranceDetails.firstRegistration.split('T')[0] : '',
noClaimsClass: c.carInsuranceDetails?.noClaimsClass || '',
insuranceType: c.carInsuranceDetails?.insuranceType || 'LIABILITY',
deductiblePartial: c.carInsuranceDetails?.deductiblePartial || '',
deductibleFull: c.carInsuranceDetails?.deductibleFull || '',
policyNumber: c.carInsuranceDetails?.policyNumber || '',
previousInsurer: c.carInsuranceDetails?.previousInsurer || '',
// Kündigungsdaten
cancellationConfirmationDate: c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : '',
cancellationConfirmationOptionsDate: c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : '',
wasSpecialCancellation: c.wasSpecialCancellation || false,
// Vorgänger-Vertrag
previousContractId: c.previousContractId?.toString() || '',
// Altanbieter (nur wenn kein Vorgängervertrag)
previousProviderId: c.previousProviderId?.toString() || '',
previousCustomerNumber: c.previousCustomerNumber || '',
previousContractNumber: c.previousContractNumber || '',
});
// Load simCards if available
if (c.mobileDetails?.simCards && c.mobileDetails.simCards.length > 0) {
setSimCards(c.mobileDetails.simCards.map(sc => ({
id: sc.id,
phoneNumber: sc.phoneNumber || '',
simCardNumber: sc.simCardNumber || '',
pin: '', // PIN/PUK werden nicht im Klartext geladen
puk: '',
hasExistingPin: !!sc.pin, // true wenn verschlüsselter Wert vorhanden
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
isMultisim: sc.isMultisim,
isMain: sc.isMain,
})));
} else {
setSimCards([]);
}
// Load phoneNumbers if available (Internet contracts)
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => ({
id: pn.id,
phoneNumber: pn.phoneNumber || '',
sipUsername: pn.sipUsername || '',
sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
hasExistingSipPassword: !!pn.sipPasswordEncrypted,
sipServer: pn.sipServer || '',
isMain: pn.isMain,
})));
} else {
setPhoneNumbers([]);
}
// Portal-Benutzername-Typ initialisieren
if (c.stressfreiEmailId) {
setUsernameType('stressfrei');
setSelectedStressfreiEmailId(c.stressfreiEmailId.toString());
} else {
setUsernameType('manual');
setSelectedStressfreiEmailId('');
}
// Mark contract as loaded so provider change detection works correctly
setIsContractLoaded(true);
}
}, [contract, reset, platformsData, contractCategoriesData, providersData, customerDetail]);
// Watch für Enddatum-Berechnung
const startDate = watch('startDate');
const contractDurationId = watch('contractDurationId');
// Berechne Vertragsende aus Beginn + Laufzeit
useEffect(() => {
if (startDate && contractDurationId && contractDurationsData?.data) {
const duration = contractDurationsData.data.find(d => d.id === parseInt(contractDurationId));
if (duration) {
const start = new Date(startDate);
const code = duration.code;
// Parse duration code (z.B. "12M", "24M", "1J", "2J")
const match = code.match(/^(\d+)([MTJ])$/);
if (match) {
const value = parseInt(match[1]);
const unit = match[2];
let endDate = new Date(start);
if (unit === 'T') {
endDate.setDate(endDate.getDate() + value);
} else if (unit === 'M') {
endDate.setMonth(endDate.getMonth() + value);
} else if (unit === 'J') {
endDate.setFullYear(endDate.getFullYear() + value);
}
// Setze das berechnete Enddatum
setValue('endDate', endDate.toISOString().split('T')[0]);
}
}
}
}, [startDate, contractDurationId, contractDurationsData, setValue]);
const createMutation = useMutation({
mutationFn: contractApi.create,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
if (variables.customerId) {
queryClient.invalidateQueries({ queryKey: ['customer', variables.customerId.toString()] });
}
queryClient.invalidateQueries({ queryKey: ['customers'] });
// Navigate back to customer's contracts tab if coming from customer
if (preselectedCustomerId) {
navigate(`/customers/${preselectedCustomerId}?tab=contracts`);
} else {
navigate('/contracts');
}
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => contractApi.update(parseInt(id!), data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['contract', id] });
if (variables.customerId) {
queryClient.invalidateQueries({ queryKey: ['customer', variables.customerId.toString()] });
}
queryClient.invalidateQueries({ queryKey: ['customers'] });
navigate(`/contracts/${id}`);
},
});
const onSubmit = (data: any) => {
// Helper to safely parse integers - returns undefined for empty/invalid values
const safeParseInt = (val: string | number | undefined | null): number | undefined => {
if (val === undefined || val === null || val === '') return undefined;
const parsed = parseInt(String(val));
return isNaN(parsed) ? undefined : parsed;
};
// Find the selected category to get its ID
const selectedCategory = contractCategories.find(c => c.code === data.type);
// Validate required fields
const customerIdParsed = safeParseInt(data.customerId);
if (!customerIdParsed) {
alert('Bitte wählen Sie einen Kunden aus');
return;
}
if (!data.type || !selectedCategory) {
alert('Bitte wählen Sie einen Vertragstyp aus');
return;
}
// Helper: Leere Strings werden zu null, damit Prisma das Feld auf NULL setzt
const emptyToNull = (val: string | undefined | null): string | null => {
if (val === undefined || val === null || val === '') return null;
return val;
};
const contractData: any = {
customerId: customerIdParsed,
type: data.type,
contractCategoryId: selectedCategory.id,
status: data.status,
addressId: safeParseInt(data.addressId) ?? null,
billingAddressId: safeParseInt(data.billingAddressId) ?? null,
bankCardId: safeParseInt(data.bankCardId) ?? null,
identityDocumentId: safeParseInt(data.identityDocumentId) ?? null,
salesPlatformId: safeParseInt(data.salesPlatformId) ?? null,
providerId: safeParseInt(data.providerId) ?? null,
tariffId: safeParseInt(data.tariffId) ?? null,
providerName: emptyToNull(data.providerName),
tariffName: emptyToNull(data.tariffName),
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
priceFirst12Months: emptyToNull(data.priceFirst12Months),
priceFrom13Months: emptyToNull(data.priceFrom13Months),
priceAfter24Months: emptyToNull(data.priceAfter24Months),
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
cancellationPeriodId: safeParseInt(data.cancellationPeriodId) ?? null,
contractDurationId: safeParseInt(data.contractDurationId) ?? null,
commission: data.commission ? parseFloat(data.commission) : null,
// Portal-Benutzername: entweder manuell oder Stressfrei-Adresse
portalUsername: usernameType === 'manual' ? emptyToNull(data.portalUsername) : null,
stressfreiEmailId: usernameType === 'stressfrei' && selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : null,
portalPassword: data.portalPassword || undefined, // Passwort: undefined = nicht ändern
notes: emptyToNull(data.notes),
cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null,
cancellationConfirmationOptionsDate: data.cancellationConfirmationOptionsDate ? new Date(data.cancellationConfirmationOptionsDate) : null,
wasSpecialCancellation: data.wasSpecialCancellation || false,
previousContractId: safeParseInt(data.previousContractId) ?? null,
// Altanbieter (nur wenn kein Vorgängervertrag)
previousProviderId: data.previousContractId ? null : (safeParseInt(data.previousProviderId) ?? null),
previousCustomerNumber: data.previousContractId ? null : emptyToNull(data.previousCustomerNumber),
previousContractNumber: data.previousContractId ? null : emptyToNull(data.previousContractNumber),
};
// Add type-specific details
if (['ELECTRICITY', 'GAS'].includes(data.type)) {
contractData.energyDetails = {
meterId: safeParseInt(data.meterId) ?? null,
maloId: emptyToNull(data.maloId),
annualConsumption: data.annualConsumption ? parseFloat(data.annualConsumption) : null,
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,
instantBonus: data.instantBonus ? parseFloat(data.instantBonus) : null,
newCustomerBonus: data.newCustomerBonus ? parseFloat(data.newCustomerBonus) : null,
};
}
if (['DSL', 'CABLE', 'FIBER'].includes(data.type)) {
contractData.internetDetails = {
downloadSpeed: safeParseInt(data.downloadSpeed) ?? null,
uploadSpeed: safeParseInt(data.uploadSpeed) ?? null,
routerModel: emptyToNull(data.routerModel),
routerSerialNumber: emptyToNull(data.routerSerialNumber),
installationDate: data.installationDate ? new Date(data.installationDate) : null,
// Internet-Zugangsdaten
internetUsername: emptyToNull(data.internetUsername),
internetPassword: data.internetPassword || undefined, // Passwort: undefined = nicht ändern
// Objekt & Lage
propertyType: emptyToNull(data.propertyType),
propertyLocation: emptyToNull(data.propertyLocation),
connectionLocation: emptyToNull(data.connectionLocation),
// Glasfaser-spezifisch
homeId: emptyToNull(data.homeId),
// Vodafone DSL/Kabel spezifisch
activationCode: emptyToNull(data.activationCode),
// Rufnummern mit SIP-Daten
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => ({
id: pn.id,
phoneNumber: pn.phoneNumber || '',
isMain: pn.isMain ?? false,
sipUsername: emptyToNull(pn.sipUsername),
sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
sipServer: emptyToNull(pn.sipServer),
})) : undefined,
};
}
if (data.type === 'MOBILE') {
contractData.mobileDetails = {
requiresMultisim: data.requiresMultisim || false,
dataVolume: data.dataVolume ? parseFloat(data.dataVolume) : null,
includedMinutes: safeParseInt(data.includedMinutes) ?? null,
includedSMS: safeParseInt(data.includedSMS) ?? null,
deviceModel: emptyToNull(data.deviceModel),
deviceImei: emptyToNull(data.deviceImei),
// Legacy
phoneNumber: emptyToNull(data.phoneNumber),
simCardNumber: emptyToNull(data.simCardNumber),
// SIM-Karten
simCards: simCards.length > 0 ? simCards.map(sc => ({
id: sc.id,
phoneNumber: emptyToNull(sc.phoneNumber),
simCardNumber: emptyToNull(sc.simCardNumber),
pin: sc.pin || undefined, // Passwort: undefined = nicht ändern
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
isMultisim: sc.isMultisim,
isMain: sc.isMain,
})) : undefined,
};
}
if (data.type === 'TV') {
contractData.tvDetails = {
receiverModel: emptyToNull(data.receiverModel),
smartcardNumber: emptyToNull(data.smartcardNumber),
package: emptyToNull(data.tvPackage),
};
}
if (data.type === 'CAR_INSURANCE') {
contractData.carInsuranceDetails = {
licensePlate: emptyToNull(data.licensePlate),
hsn: emptyToNull(data.hsn),
tsn: emptyToNull(data.tsn),
vin: emptyToNull(data.vin),
vehicleType: emptyToNull(data.vehicleType),
firstRegistration: data.firstRegistration ? new Date(data.firstRegistration) : null,
noClaimsClass: emptyToNull(data.noClaimsClass),
insuranceType: data.insuranceType,
deductiblePartial: data.deductiblePartial ? parseFloat(data.deductiblePartial) : null,
deductibleFull: data.deductibleFull ? parseFloat(data.deductibleFull) : null,
policyNumber: emptyToNull(data.policyNumber),
previousInsurer: emptyToNull(data.previousInsurer),
};
}
if (isEdit) {
updateMutation.mutate(contractData);
} else {
createMutation.mutate(contractData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const error = createMutation.error || updateMutation.error;
const customer = customerDetail?.data;
const addresses = customer?.addresses || [];
const bankCards = customer?.bankCards?.filter((c) => c.isActive) || [];
const documents = customer?.identityDocuments?.filter((d) => d.isActive) || [];
const meters = customer?.meters || [];
const selectedMeter = meters.find(m => m.id.toString() === selectedMeterId);
// Verbrauch aus Vorvertrag berechnen (für Übernehmen-Hinweis am Jahresverbrauch).
// Nutzt die clientseitige Verbrauchsberechnung egal ob Single- oder Multi-Meter,
// egal ob exakt oder hochgerechnet.
const previousConsumption = useMemo(() => {
const pc = previousContractData?.data;
if (!pc?.energyDetails || !pc.startDate || !pc.endDate) return null;
if (!['ELECTRICITY', 'GAS'].includes(pc.type)) return null;
const cms = pc.energyDetails.contractMeters || [];
if (cms.length > 0) {
return calculateMultiMeterConsumption(cms, pc.startDate, pc.endDate, pc.type as 'ELECTRICITY' | 'GAS');
}
const readings = pc.energyDetails.meter?.readings || [];
if (readings.length === 0) return null;
return calculateConsumption(readings, pc.startDate, pc.endDate, pc.type as 'ELECTRICITY' | 'GAS');
}, [previousContractData]);
const previousConsumptionUsable = previousConsumption
&& (previousConsumption.type === 'exact' || previousConsumption.type === 'projected');
const stressfreiEmails = customer?.stressfreiEmails?.filter((e: { isActive: boolean }) => e.isActive) || [];
const platforms = platformsData?.data || [];
const cancellationPeriods = cancellationPeriodsData?.data || [];
const contractDurations = contractDurationsData?.data || [];
const providers = providersData?.data?.filter(p => p.isActive) || [];
const contractCategories = contractCategoriesData?.data?.filter(c => c.isActive).sort((a, b) => a.sortOrder - b.sortOrder) || [];
const typeOptions = contractCategories.map(c => ({ value: c.code, label: c.name }));
// Available predecessor contracts (same customer, excluding current contract if editing)
const predecessorContracts = (customerContractsData?.data || [])
.filter(c => !isEdit || c.id !== parseInt(id!))
.sort((a, b) => new Date(b.startDate || 0).getTime() - new Date(a.startDate || 0).getTime());
// Get tariffs for selected provider
const selectedProvider = providers.find(p => p.id === parseInt(selectedProviderId || '0'));
const availableTariffs = selectedProvider?.tariffs?.filter(t => t.isActive) || [];
// Helper to format customer label with optional birth date
const formatCustomerLabel = (c: { customerNumber: string; companyName?: string; firstName: string; lastName: string; birthDate?: string }) => {
const name = c.companyName || `${c.firstName} ${c.lastName}`;
const birthDateStr = c.birthDate
? ` (geb. ${new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })})`
: '';
return `${c.customerNumber} - ${name}${birthDateStr}`;
};
// Build customer options, ensuring current customer from contract is always included
const customerOptions = (() => {
const customers = customersData?.data || [];
const options = customers.map((c) => ({
value: c.id.toString(),
label: formatCustomerLabel(c),
}));
// If editing and contract's customer isn't in the list yet, add it
if (isEdit && contract?.data?.customer) {
const contractCustomer = contract.data.customer;
const exists = options.some((o) => o.value === contractCustomer.id.toString());
if (!exists) {
options.unshift({
value: contractCustomer.id.toString(),
label: formatCustomerLabel(contractCustomer),
});
}
}
return options;
})();
return (
<div>
<div className="flex items-center gap-4 mb-6">
<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">
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
</h1>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten'}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Card className="mb-6" title="Vertragsdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Kunde *"
{...register('customerId', { required: 'Kunde erforderlich' })}
options={customerOptions}
error={errors.customerId?.message as string}
/>
<Select
label="Vertragstyp *"
{...register('type', { required: 'Typ erforderlich' })}
options={typeOptions}
/>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-sm font-medium text-gray-700">Status</label>
<button
type="button"
onClick={() => setShowStatusInfo(true)}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Status-Erklärung"
>
<Info className="w-4 h-4" />
</button>
</div>
<Select
{...register('status')}
options={statusOptions}
/>
</div>
<Select
label="Vertriebsplattform"
{...register('salesPlatformId')}
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
/>
{/* Vorgänger-Vertrag auswählen (nur wenn Kunde gewählt) */}
{customerId && (
<Select
label="Vorgänger-Vertrag"
{...register('previousContractId')}
options={predecessorContracts.map((c) => ({
value: c.id,
label: `${c.contractNumber} (${c.type}${c.startDate ? ` - ${formatDate(c.startDate)}` : ''})`,
}))}
placeholder="Keinen Vorgänger auswählen"
/>
)}
{/* Altanbieter-Daten (nur wenn KEIN Vorgängervertrag ausgewählt) */}
{customerId && !previousContractId && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h4 className="text-sm font-medium text-gray-700 mb-3">Altanbieter-Daten</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Select
label="Altanbieter"
{...register('previousProviderId')}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
placeholder="Bitte wählen..."
/>
<Input
label="Kundennr. beim Altanbieter"
{...register('previousCustomerNumber')}
/>
<Input
label="Vertragsnr. beim Altanbieter"
{...register('previousContractNumber')}
/>
</div>
</div>
)}
</div>
</Card>
{customerId && (
<Card className="mb-6" title="Kundendaten verknüpfen">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Select
label="Lieferadresse"
{...register('addressId')}
options={addresses
.filter((a) => a.type === 'DELIVERY_RESIDENCE')
.map((a) => ({
value: a.id,
label: `${a.street} ${a.houseNumber}, ${a.postalCode} ${a.city}`,
}))}
/>
<Select
label="Rechnungsadresse"
{...register('billingAddressId')}
options={addresses
.filter((a) => a.type === 'BILLING')
.map((a) => ({
value: a.id,
label: `${a.street} ${a.houseNumber}, ${a.postalCode} ${a.city}`,
}))}
placeholder="Wie Lieferadresse"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Bankkarte"
{...register('bankCardId')}
options={bankCards.map((c) => ({
value: c.id,
label: `${c.iban} (${c.accountHolder})`,
}))}
/>
<Select
label="Ausweis"
{...register('identityDocumentId')}
options={documents.map((d) => ({
value: d.id,
label: `${d.documentNumber} (${d.type})`,
}))}
/>
</div>
</Card>
)}
<Card className="mb-6" title="Anbieter & Tarif">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Anbieter"
{...register('providerId')}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
<Select
label="Tarif"
{...register('tariffId')}
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
disabled={!selectedProviderId}
/>
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
<Input label="Preis nach 24 Monaten" {...register('priceAfter24Months')} placeholder="z.B. 49,99 €/Monat" />
</div>
</Card>
<Card className="mb-6" title="Laufzeit und Kündigung">
<p className="text-sm text-gray-500 mb-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
<strong>Hinweis:</strong> Ist die Laufzeit 4 Wochen, 1 Monat oder 30 Tage, gilt der Vertrag als unbefristet mit der jeweiligen Kündigungsfrist.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Vertragsbeginn"
type="date"
{...register('startDate')}
value={watch('startDate') || ''}
onClear={() => setValue('startDate', '')}
/>
<Input label="Vertragsende (berechnet)" type="date" {...register('endDate')} disabled className="bg-gray-50" />
<Select
label="Vertragslaufzeit"
{...register('contractDurationId')}
options={contractDurations.map((cd) => ({ value: cd.id, label: cd.description }))}
/>
<Select
label="Kündigungsfrist"
{...register('cancellationPeriodId')}
options={cancellationPeriods.map((cp) => ({ value: cp.id, label: cp.description }))}
/>
<Input
label="Kündigungsbestätigungsdatum"
type="date"
{...register('cancellationConfirmationDate')}
value={watch('cancellationConfirmationDate') || ''}
onClear={() => setValue('cancellationConfirmationDate', '')}
/>
<Input
label="Kündigungsbestätigungsoptionendatum"
type="date"
{...register('cancellationConfirmationOptionsDate')}
value={watch('cancellationConfirmationOptionsDate') || ''}
onClear={() => setValue('cancellationConfirmationOptionsDate', '')}
/>
<div className="col-span-2">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" {...register('wasSpecialCancellation')} className="rounded border-gray-300" />
<span>Wurde sondergekündigt?</span>
</label>
</div>
</div>
</Card>
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Portal Benutzername</label>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="usernameType"
checked={usernameType === 'manual'}
onChange={() => {
setUsernameType('manual');
setSelectedStressfreiEmailId('');
}}
className="text-blue-600"
/>
<span className="text-sm">Manuell eingeben</span>
</label>
{usernameType === 'manual' && (
<Input
{...register('portalUsername')}
placeholder="Benutzername eingeben..."
/>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="usernameType"
checked={usernameType === 'stressfrei'}
onChange={() => {
setUsernameType('stressfrei');
setValue('portalUsername', '');
}}
className="text-blue-600"
/>
<span className="text-sm">{customerEmailLabel} Adresse</span>
</label>
{usernameType === 'stressfrei' && (
<Select
value={selectedStressfreiEmailId}
onChange={(e) => setSelectedStressfreiEmailId(e.target.value)}
options={stressfreiEmails.map((e: { id: number; email: string; notes?: string }) => ({
value: e.id,
label: e.email + (e.notes ? ` (${e.notes})` : ''),
}))}
placeholder={stressfreiEmails.length === 0 ? 'Keine Stressfrei-Adressen vorhanden' : 'Adresse auswählen...'}
/>
)}
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
<p className="text-xs text-amber-600">
Keine {customerEmailLabel} Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.
</p>
)}
</div>
</div>
<div className="mt-8">
<label className="block text-sm font-medium text-gray-700 mb-1">
{isEdit ? 'Neues Passwort (leer lassen = unverändert)' : 'Portal Passwort'}
</label>
<div className="relative">
<input
type={showPortalPassword ? 'text' : 'password'}
{...register('portalPassword')}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPortalPassword(!showPortalPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPortalPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</Card>
{/* Energy-specific fields */}
{['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">
{(() => {
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')}
/>
<div>
<Input
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
type="number"
{...register('annualConsumption')}
/>
{previousConsumptionUsable && (
<p className="mt-1 text-xs text-blue-600 flex items-center gap-2">
<span>
Vorvertrag: {(contractType === 'GAS'
? previousConsumption!.consumptionM3 ?? previousConsumption!.consumptionKwh
: previousConsumption!.consumptionKwh
).toLocaleString('de-DE', { maximumFractionDigits: 0 })} {contractType === 'GAS' ? 'm³' : 'kWh'}
{previousConsumption!.type === 'projected' && ' (hochgerechnet)'}
</span>
<button
type="button"
className="text-blue-700 hover:underline"
onClick={() => {
const v = contractType === 'GAS'
? previousConsumption!.consumptionM3 ?? previousConsumption!.consumptionKwh
: previousConsumption!.consumptionKwh;
setValue('annualConsumption', Math.round(v) as any, { shouldDirty: true });
}}
>
Übernehmen
</button>
</p>
)}
</div>
{contractType === 'GAS' && (
<div>
<Input
label="Jahresverbrauch (kWh)"
type="number"
{...register('annualConsumptionKwh')}
/>
{previousConsumptionUsable && (
<p className="mt-1 text-xs text-blue-600 flex items-center gap-2">
<span>
Vorvertrag: {previousConsumption!.consumptionKwh.toLocaleString('de-DE', { maximumFractionDigits: 0 })} kWh
{previousConsumption!.type === 'projected' && ' (hochgerechnet)'}
</span>
<button
type="button"
className="text-blue-700 hover:underline"
onClick={() => setValue('annualConsumptionKwh', Math.round(previousConsumption!.consumptionKwh) as any, { shouldDirty: true })}
>
Übernehmen
</button>
</p>
)}
</div>
)}
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
<EuroCentInput
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
value={watch('unitPrice')}
onChange={(v) => setValue('unitPrice', v, { shouldDirty: true })}
/>
{selectedMeter?.tariffModel === 'DUAL' && (
<EuroCentInput
label="NT-Arbeitspreis (€/kWh)"
value={watch('unitPriceNt')}
onChange={(v) => setValue('unitPriceNt', v, { shouldDirty: true })}
/>
)}
<Input label="Sofort-Bonus (€)" type="number" step="0.01" {...register('instantBonus')} />
<Input label="Neukunden-Bonus (€)" type="number" step="0.01" {...register('newCustomerBonus')} />
</div>
{/* Hinweis für Zählerstände und Rechnungen */}
{isEdit && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
<strong>Hinweis:</strong> Zählerstände und Rechnungen werden in der{' '}
<span className="font-medium">Vertragsdetailansicht</span> verwaltet, nicht hier im Bearbeitungsformular.
</div>
)}
</Card>
)}
{/* Internet-specific fields */}
{['DSL', 'CABLE', 'FIBER'].includes(contractType) && (
<>
<Card className="mb-6" title={
contractType === 'DSL' ? 'DSL-Details' :
contractType === 'CABLE' ? 'Kabelinternet-Details' :
'Glasfaser-Details'
}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Download (Mbit/s)" type="number" {...register('downloadSpeed')} />
<Input label="Upload (Mbit/s)" type="number" {...register('uploadSpeed')} />
<Input label="Router Modell" {...register('routerModel')} />
<Input label="Router Seriennummer" {...register('routerSerialNumber')} />
<Input
label="Installationsdatum"
type="date"
{...register('installationDate')}
value={watch('installationDate') || ''}
onClear={() => setValue('installationDate', '')}
/>
</div>
{/* Objekt & Lage */}
<h4 className="text-sm font-medium text-gray-700 mt-4 mb-2">Objekt & Lage</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Select
label="Objekttyp"
{...register('propertyType')}
options={[
{ value: '', label: 'Bitte wählen...' },
{ value: 'Mehrparteienhaus', label: 'Mehrparteienhaus' },
{ value: 'Freistehendes Haus', label: 'Freistehendes Haus' },
{ value: 'Doppelhaushälfte', label: 'Doppelhaushälfte' },
{ value: 'Reihenhaus', label: 'Reihenhaus' },
{ value: 'Wohnung', label: 'Wohnung' },
{ value: 'Bürogebäude', label: 'Bürogebäude' },
{ value: 'Gewerbeeinheit', label: 'Gewerbeeinheit' },
]}
/>
<Select
label="Lage"
{...register('propertyLocation')}
options={[
{ value: '', label: 'Bitte wählen...' },
{ value: 'Vorderhaus', label: 'Vorderhaus' },
{ value: 'Hinterhaus', label: 'Hinterhaus' },
{ value: 'Links', label: 'Links' },
{ value: 'Rechts', label: 'Rechts' },
{ value: 'Mitte', label: 'Mitte' },
{ value: 'Keller', label: 'Keller' },
{ value: 'Souterrain', label: 'Souterrain' },
{ value: 'Erdgeschoss', label: 'Erdgeschoss' },
...[...Array(25)].map((_, i) => ({ value: `${i + 1}. OG`, label: `${i + 1}. Obergeschoss` })),
{ value: 'Dachgeschoss', label: 'Dachgeschoss' },
]}
/>
<Select
label="Lage des Anschlusses"
{...register('connectionLocation')}
options={[
{ value: '', label: 'Bitte wählen...' },
{ value: 'Flur', label: 'Flur' },
{ value: 'Wohnzimmer', label: 'Wohnzimmer' },
{ value: 'Schlafzimmer', label: 'Schlafzimmer' },
{ value: 'Kinderzimmer', label: 'Kinderzimmer' },
{ value: 'Küche', label: 'Küche' },
{ value: 'Büro', label: 'Büro' },
{ value: 'HWR', label: 'Hauswirtschaftsraum (HWR)' },
{ value: 'Hausanschlussraum', label: 'Hausanschlussraum' },
{ value: 'Abstellraum', label: 'Abstellraum' },
{ value: 'Garage', label: 'Garage' },
{ value: 'Serverraum', label: 'Serverraum' },
{ value: 'Empfang', label: 'Empfang / Rezeption' },
{ value: 'Keller', label: 'Keller' },
]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{/* HomeID nur bei Glasfaser */}
{contractType === 'FIBER' && (
<Input label="Home-ID" {...register('homeId')} />
)}
{/* Aktivierungscode bei Vodafone + DSL oder Kabel */}
{selectedProvider?.name?.toLowerCase().includes('vodafone') && ['DSL', 'CABLE'].includes(contractType) && (
<Input label="Aktivierungscode" {...register('activationCode')} />
)}
</div>
</Card>
<Card className="mb-6" title="Internet-Zugangsdaten (verschlüsselt)">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Benutzername" {...register('internetUsername')} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{isEdit ? 'Neues Passwort (leer = beibehalten)' : 'Passwort'}
</label>
<div className="relative">
<input
type={showInternetPassword ? 'text' : 'password'}
{...register('internetPassword')}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowInternetPassword(!showInternetPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showInternetPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</Card>
<Card className="mb-6" title="Rufnummern & SIP-Zugangsdaten">
<p className="text-sm text-gray-500 mb-4">
Hier können Sie Festnetz-Rufnummern mit SIP-Zugangsdaten erfassen.
</p>
{phoneNumbers.length > 0 && (
<div className="space-y-4 mb-4">
{phoneNumbers.map((pn, index) => (
<div key={index} className="p-4 border rounded-lg bg-gray-50">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<span className="font-medium">Rufnummer {index + 1}</span>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={pn.isMain}
onChange={(e) => {
const updated = [...phoneNumbers];
if (e.target.checked) {
updated.forEach((p, i) => p.isMain = i === index);
} else {
updated[index].isMain = false;
}
setPhoneNumbers(updated);
}}
className="rounded border-gray-300"
/>
Hauptnummer
</label>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setPhoneNumbers(phoneNumbers.filter((_, i) => i !== index));
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<Input
label="Rufnummer"
value={pn.phoneNumber}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].phoneNumber = e.target.value;
setPhoneNumbers(updated);
}}
placeholder="z.B. 030 123456"
/>
<Input
label="SIP-Benutzername"
value={pn.sipUsername}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].sipUsername = e.target.value;
setPhoneNumbers(updated);
}}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{pn.hasExistingSipPassword ? "SIP-Passwort (bereits hinterlegt)" : "SIP-Passwort"}
</label>
<div className="relative">
<input
type={showSipPasswords[index] ? 'text' : 'password'}
value={pn.sipPassword}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].sipPassword = e.target.value;
setPhoneNumbers(updated);
}}
placeholder={pn.hasExistingSipPassword ? "Leer = beibehalten" : ""}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowSipPasswords(prev => ({ ...prev, [index]: !prev[index] }))}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showSipPasswords[index] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Input
label="SIP-Server"
value={pn.sipServer}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].sipServer = e.target.value;
setPhoneNumbers(updated);
}}
placeholder="z.B. sip.provider.de"
/>
</div>
</div>
))}
</div>
)}
<Button
type="button"
variant="secondary"
onClick={() => {
setPhoneNumbers([...phoneNumbers, {
phoneNumber: '',
sipUsername: '',
sipPassword: '',
sipServer: '',
isMain: phoneNumbers.length === 0,
}]);
}}
>
<Plus className="w-4 h-4 mr-2" />
Rufnummer hinzufügen
</Button>
</Card>
</>
)}
{/* Mobile-specific fields */}
{contractType === 'MOBILE' && (
<>
<Card className="mb-6" title="Mobilfunk-Details">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Datenvolumen (GB)" type="number" {...register('dataVolume')} />
<Input label="Inklusiv-Minuten" type="number" {...register('includedMinutes')} />
<Input label="Inklusiv-SMS" type="number" {...register('includedSMS')} />
<Input label="Gerät (Modell)" {...register('deviceModel')} />
<Input label="Gerät (IMEI)" {...register('deviceImei')} />
</div>
{/* Multisim erforderlich */}
<div className="mt-4 pt-4 border-t">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
{...register('requiresMultisim')}
className="mt-1 rounded border-gray-300"
/>
<div>
<span className="font-medium">Multisim erforderlich</span>
<p className="text-sm text-amber-600 mt-1">
Hinweis: Multisim ist bei Klarmobil, Congstar und Otelo nicht buchbar. Muss Freenet oder vergleichbar sein.
</p>
</div>
</label>
</div>
</Card>
<Card className="mb-6" title="SIM-Karten">
<p className="text-sm text-gray-500 mb-4">
Hier können Sie alle SIM-Karten zum Vertrag erfassen (Hauptkarte und Multisim-Karten).
</p>
{simCards.length > 0 && (
<div className="space-y-4 mb-4">
{simCards.map((card, index) => (
<div key={index} className="p-4 border rounded-lg bg-gray-50">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<span className="font-medium">SIM-Karte {index + 1}</span>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={card.isMain}
onChange={(e) => {
const updated = [...simCards];
// Nur eine Hauptkarte erlauben
if (e.target.checked) {
updated.forEach((c, i) => c.isMain = i === index);
} else {
updated[index].isMain = false;
}
setSimCards(updated);
}}
className="rounded border-gray-300"
/>
Hauptkarte
</label>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={card.isMultisim}
onChange={(e) => {
const updated = [...simCards];
updated[index].isMultisim = e.target.checked;
setSimCards(updated);
}}
className="rounded border-gray-300"
/>
Multisim
</label>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setSimCards(simCards.filter((_, i) => i !== index));
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<Input
label="Rufnummer"
value={card.phoneNumber}
onChange={(e) => {
const updated = [...simCards];
updated[index].phoneNumber = e.target.value;
setSimCards(updated);
}}
placeholder="z.B. 0171 1234567"
/>
<Input
label="SIM-Kartennummer"
value={card.simCardNumber}
onChange={(e) => {
const updated = [...simCards];
updated[index].simCardNumber = e.target.value;
setSimCards(updated);
}}
placeholder="ICCID"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{card.hasExistingPin ? "PIN (bereits hinterlegt)" : "PIN"}
</label>
<div className="relative">
<input
type={showSimPins[index] ? 'text' : 'password'}
value={card.pin}
onChange={(e) => {
const updated = [...simCards];
updated[index].pin = e.target.value;
setSimCards(updated);
}}
placeholder={card.hasExistingPin ? "Leer = beibehalten" : "4-stellig"}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowSimPins(prev => ({ ...prev, [index]: !prev[index] }))}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showSimPins[index] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{card.hasExistingPuk ? "PUK (bereits hinterlegt)" : "PUK"}
</label>
<div className="relative">
<input
type={showSimPuks[index] ? 'text' : 'password'}
value={card.puk}
onChange={(e) => {
const updated = [...simCards];
updated[index].puk = e.target.value;
setSimCards(updated);
}}
placeholder={card.hasExistingPuk ? "Leer = beibehalten" : "8-stellig"}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowSimPuks(prev => ({ ...prev, [index]: !prev[index] }))}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showSimPuks[index] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
<Button
type="button"
variant="secondary"
onClick={() => {
setSimCards([...simCards, {
phoneNumber: '',
simCardNumber: '',
pin: '',
puk: '',
isMultisim: false,
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
}]);
}}
>
<Plus className="w-4 h-4 mr-2" />
SIM-Karte hinzufügen
</Button>
</Card>
</>
)}
{/* TV-specific fields */}
{contractType === 'TV' && (
<Card className="mb-6" title="TV-Details">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Receiver Modell" {...register('receiverModel')} />
<Input label="Smartcard-Nummer" {...register('smartcardNumber')} />
<Input label="Paket" {...register('tvPackage')} placeholder="z.B. Basis, Premium, Sport" />
</div>
</Card>
)}
{/* Car Insurance-specific fields */}
{contractType === 'CAR_INSURANCE' && (
<Card className="mb-6" title="KFZ-Versicherung Details">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Kennzeichen" {...register('licensePlate')} />
<Input label="HSN" {...register('hsn')} />
<Input label="TSN" {...register('tsn')} />
<Input label="FIN (VIN)" {...register('vin')} />
<Input label="Fahrzeugtyp" {...register('vehicleType')} />
<Input
label="Erstzulassung"
type="date"
{...register('firstRegistration')}
value={watch('firstRegistration') || ''}
onClear={() => setValue('firstRegistration', '')}
/>
<Input label="SF-Klasse" {...register('noClaimsClass')} />
<Select
label="Versicherungsart"
{...register('insuranceType')}
options={[
{ value: 'LIABILITY', label: 'Haftpflicht' },
{ value: 'PARTIAL', label: 'Teilkasko' },
{ value: 'FULL', label: 'Vollkasko' },
]}
/>
<Input label="SB Teilkasko (€)" type="number" {...register('deductiblePartial')} />
<Input label="SB Vollkasko (€)" type="number" {...register('deductibleFull')} />
<Input label="Versicherungsscheinnummer" {...register('policyNumber')} />
<Input label="Vorversicherer" {...register('previousInsurer')} />
</div>
</Card>
)}
<Card className="mb-6" title="Notizen">
<textarea
{...register('notes')}
rows={4}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Interne Notizen..."
/>
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
</div>
);
}
/**
* Doppel-Input für Arbeitspreise: links €/kWh, rechts ct/kWh.
* Beide bidirektional verkoppelt Tippen im Cent-Feld setzt das
* darunterliegende Euro-Feld (÷ 100), und umgekehrt. Persistiert
* wird ausschließlich der Euro-Wert (Backend-Format unverändert).
* Cent-Anzeige wird live aus dem Euro-Wert abgeleitet.
*/
function EuroCentInput({
label,
value,
onChange,
}: {
label: string;
value: string | number | undefined | null;
onChange: (v: string) => void;
}) {
// Euro-Wert als String wie die anderen Inputs (react-hook-form merged
// numeric / string-Werte). Wir trimmen trailing zeros bei der
// Cent-Anzeige, damit "0.25 €" sauber als "25 ct" angezeigt wird
// ohne "25.000000".
const euroStr = value == null ? '' : String(value);
const centDisplay = (() => {
if (euroStr === '') return '';
const n = parseFloat(euroStr);
if (!Number.isFinite(n)) return '';
// × 100 mit Float-Rauschen-Schutz (0.25 * 100 = 25.000000000000004)
const c = Math.round(n * 100 * 1_000_000) / 1_000_000;
return String(c);
})();
const handleCentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '') {
onChange('');
return;
}
const num = parseFloat(raw);
if (!Number.isFinite(num)) {
onChange('');
return;
}
// cent / 100, Float-Rauschen-Schutz analog.
const euro = Math.round((num / 100) * 1_000_000) / 1_000_000;
onChange(String(euro));
};
const inputCls =
'block w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
return (
<div className="w-full">
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<div className="grid grid-cols-2 gap-2">
<div className="relative">
<input
type="number"
step="any"
value={euroStr}
onChange={(e) => onChange(e.target.value)}
className={inputCls}
/>
<span className="absolute right-3 top-2 text-gray-400 text-sm pointer-events-none"></span>
</div>
<div className="relative">
<input
type="number"
step="any"
value={centDisplay}
onChange={handleCentChange}
className={inputCls}
/>
<span className="absolute right-3 top-2 text-gray-400 text-sm pointer-events-none">ct</span>
</div>
</div>
</div>
);
}