1382 lines
61 KiB
TypeScript
1382 lines
61 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
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';
|
|
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 { Plus, Trash2, Eye, EyeOff, Info, X } from 'lucide-react';
|
|
|
|
// 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 queryClient = useQueryClient();
|
|
const isEdit = !!id;
|
|
|
|
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');
|
|
|
|
// 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,
|
|
});
|
|
|
|
// 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 || '',
|
|
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 || '',
|
|
bonus: c.energyDetails?.bonus || '',
|
|
previousProviderName: c.energyDetails?.previousProviderName || '',
|
|
previousCustomerNumber: c.energyDetails?.previousCustomerNumber || '',
|
|
// 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 || '',
|
|
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() || '',
|
|
});
|
|
|
|
// 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),
|
|
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,
|
|
};
|
|
|
|
// 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,
|
|
bonus: data.bonus ? parseFloat(data.bonus) : null,
|
|
previousProviderName: emptyToNull(data.previousProviderName),
|
|
previousCustomerNumber: emptyToNull(data.previousCustomerNumber),
|
|
};
|
|
}
|
|
|
|
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
|
|
// 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?.filter((m) => m.isActive) || [];
|
|
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>
|
|
<h1 className="text-2xl font-bold mb-6">
|
|
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
|
</h1>
|
|
|
|
{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 ? ` - ${new Date(c.startDate).toLocaleDateString('de-DE')}` : ''})`,
|
|
}))}
|
|
placeholder="Keinen Vorgänger auswählen"
|
|
/>
|
|
)}
|
|
</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="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="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">Stressfrei-Wechseln 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 Stressfrei-Wechseln 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">
|
|
<Select
|
|
label="Zähler"
|
|
{...register('meterId')}
|
|
options={meters
|
|
.filter((m) => m.type === contractType)
|
|
.map((m) => ({
|
|
value: m.id,
|
|
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}`,
|
|
}))}
|
|
/>
|
|
<Input
|
|
label="MaLo-ID (Marktlokations-ID)"
|
|
{...register('maloId')}
|
|
/>
|
|
<Input
|
|
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
|
type="number"
|
|
{...register('annualConsumption')}
|
|
/>
|
|
{contractType === 'GAS' && (
|
|
<Input
|
|
label="Jahresverbrauch (kWh)"
|
|
type="number"
|
|
{...register('annualConsumptionKwh')}
|
|
/>
|
|
)}
|
|
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
|
|
<Input
|
|
label="Arbeitspreis (€/kWh)"
|
|
type="number"
|
|
step="any"
|
|
{...register('unitPrice')}
|
|
/>
|
|
<Input label="Bonus (€)" type="number" step="0.01" {...register('bonus')} />
|
|
<Input label="Vorversorger" {...register('previousProviderName')} />
|
|
<Input label="Kundennr. beim Vorversorger" {...register('previousCustomerNumber')} />
|
|
</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', '')}
|
|
/>
|
|
{/* 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(-1)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="submit" disabled={isLoading}>
|
|
{isLoading ? 'Speichern...' : 'Speichern'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Status-Info Modal */}
|
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
|
</div>
|
|
);
|
|
}
|