Files
opencrm/frontend/src/pages/contracts/ContractForm.tsx
T
duffyduckandClaude Opus 4.8 aff3f7e84a Update-Banner: klebt oben fest (sticky), Sticky-Header rasten darunter ein
Der Hinweis "Neue Version verfuegbar" scrollte bisher weg. Jetzt sticky an
der Viewport-Oberkante; das Banner meldet seine gemessene Hoehe als CSS-Var
--app-banner-h, an der sich die Sticky-Header von ContractDetail/ContractForm
ausrichten (top-[var(--app-banner-h,0px)]), damit sie nicht dahinter
verschwinden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 12:20:31 +02:00

2091 lines
97 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, Link } 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 CopyButton from '../../components/ui/CopyButton';
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
import { buildContractLabelParts } from '../../utils/contractLabel';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft, ExternalLink, Mail, Copy } from 'lucide-react';
// Kleine Helper-Komponente: Label + Mini-Link im neuen Tab. Wird neben
// Select-Feldern eingesetzt, deren Stammdaten an anderer Stelle gepflegt
// werden (Kundenakte, Settings) damit der User direkt von dort in die
// passende Verwaltungsseite springen kann.
function LabelWithLink({ children, to, title }: { children: React.ReactNode; to: string; title: string }) {
return (
<span className="inline-flex items-center gap-2">
<span>{children}</span>
<Link
to={to}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 hover:text-blue-800"
title={title}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3 h-3" />
</Link>
</span>
);
}
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: 'ONGOING', label: 'Läuft weiter (unbestimmt)' },
{ 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: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
{ 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');
// Kopier-Modus: `?copyFrom=<id>` legt einen NEUEN, eigenständigen Vertrag
// an, aber mit allen Daten eines Bestandsvertrags vorbefüllt. Kein
// Bearbeiten (isEdit bleibt false) → beim Speichern entsteht ein frischer
// Vertrag. Unique-/instanzspezifische Felder werden beim Prefill geleert.
const copyFromId = searchParams.get('copyFrom');
const isCopy = !isEdit && !!copyFromId;
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');
const selectedBankCardId = watch('bankCardId');
const selectedDocumentId = watch('identityDocumentId');
// Fetch existing contract for edit
const { data: contract } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(parseInt(id!)),
enabled: isEdit,
});
// Fetch source contract for copy mode
const { data: copySource } = useQuery({
queryKey: ['contract', 'copy', copyFromId],
queryFn: () => contractApi.getById(parseInt(copyFromId!)),
enabled: isCopy,
});
// 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],
// includeDeactivated: auch deaktivierte Verträge als möglichen Vorgänger
// anbieten sonst geht die Verknüpfung verloren, wenn man einen
// Folgevertrag aus einem deaktivierten Vertrag anlegt/bearbeitet.
queryFn: () => contractApi.getAll({ customerId: parseInt(customerId), limit: 1000, includeDeactivated: true }),
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;
isEsim: boolean;
cardUser: string;
}
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
// Rufnummern State für Internet-Verträge
interface PhoneNumberInput {
id?: number;
// areaCode + subscriber sind die getrennten Eingabefelder.
// phoneNumber-Feld in der DB ist immer die zusammengesetzte
// "Vorwahl Anschluss"-Form, wird im Submit kombiniert.
areaCode: string;
subscriber: 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' | 'not_required'>('manual');
const [selectedStressfreiEmailId, setSelectedStressfreiEmailId] = useState<string>('');
// Passwort-Sichtbarkeit
const [showPortalPassword, setShowPortalPassword] = useState(false);
const [showCustomerInfo, setShowCustomerInfo] = 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());
} else if (isCopy && copySource?.data && !customerId) {
setValue('customerId', copySource.data.customerId.toString());
}
}, [isEdit, contract, isCopy, copySource, 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(() => {
const src = isEdit ? contract?.data : (isCopy ? copySource?.data : null);
if (src && platformsData?.data && contractCategoriesData?.data && providersData?.data && customerDetail?.data) {
const c = src;
const values: any = {
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 || '',
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
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,
mobileNetwork: c.mobileDetails?.mobileNetwork || '',
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 || '',
};
// Kopier-Modus: instanz-spezifische / eindeutige Felder leeren, damit
// ein echter neuer eigenständiger Vertrag entsteht und keine Nummern
// versehentlich doppelt vergeben werden. Der Rest (Tarif, Anbieter,
// Preise, Detailfelder …) bleibt als Vorlage erhalten und wird vom
// User bei Bedarf angepasst.
if (isCopy) {
Object.assign(values, {
status: 'DRAFT',
previousContractId: '',
// Neuer Vertrag = neuer Zeitraum
startDate: '',
endDate: '',
installationDate: '',
cancellationConfirmationDate: '',
cancellationConfirmationOptionsDate: '',
wasSpecialCancellation: false,
// Legacy-Mobilfunk-Felder (kein sichtbares Formularfeld mehr, aber
// im Reset gesetzt): eindeutige Rufnummer/SIM-Nummer ebenfalls
// leeren, sonst trügen zwei getrennte Verträge desselben Kunden
// unbemerkt dieselbe Rufnummer (Pentest R135/136). Die SIM-Karten-
// Liste wird ohnehin schon nicht übernommen.
phoneNumber: '',
simCardNumber: '',
// Eindeutige Kunden-/Vertragsnummern beim Anbieter & Plattform
customerNumberAtProvider: '',
contractNumberAtProvider: '',
orderNumberAtSalesPlatform: '',
customerNumberAtSalesPlatform: '',
contractNumberAtSalesPlatform: '',
// Portal-Zugangsdaten (Benutzername/Stressfrei-Verknüpfung) bewusst
// BEHALTEN bei gleichem Anbieter oft identisch. Das Passwort liegt
// verschlüsselt vor und wird (wie beim Bearbeiten) nicht ins Feld
// geladen; es bleibt leer und kann bei Bedarf neu gesetzt werden.
});
}
reset(values);
// SIM-Karten & Rufnummern sind hardware-/vertragsspezifisch (eindeutige
// Karten-/Telefonnummern) → im Kopier-Modus NICHT übernehmen.
// Load simCards if available
if (!isCopy && 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,
isEsim: sc.isEsim ?? false,
cardUser: sc.cardUser || '',
})));
} else {
setSimCards([]);
}
// Load phoneNumbers if available (Internet contracts)
if (!isCopy && c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => {
// Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
// heuristisch splitten (dann sollte der User das Ergebnis prüfen
// und beim nächsten Speichern fixen).
const splitLegacy = (s: string) => {
const cleaned = s.replace(/[()]/g, '').trim();
const sep = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/);
if (sep) return { ac: sep[1], rest: sep[2].replace(/[\s\-/]/g, '') };
const noSep = cleaned.match(/^(0\d{1,4})(\d{3,})$/);
if (noSep) return { ac: noSep[1], rest: noSep[2] };
return { ac: '', rest: cleaned };
};
const full = pn.phoneNumber || '';
let ac = pn.areaCode || '';
let rest = '';
if (ac && full.startsWith(ac)) {
rest = full.slice(ac.length).replace(/^[\s\-/]+/, '').trim();
} else if (ac) {
rest = full.replace(ac, '').replace(/^[\s\-/]+/, '').trim();
} else {
const s = splitLegacy(full);
ac = s.ac;
rest = s.rest;
}
return {
id: pn.id,
areaCode: ac,
subscriber: rest,
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.
// Opt-out („nicht benötigt") gewinnt vor Stressfrei/Manual, damit ein
// versehentlich noch alter portalUsername den Radio-Zustand nicht
// überstimmt. Gilt auch im Kopier-Modus: Portal-Zugangsdaten werden
// mitkopiert (nur das Passwort bleibt leer, s.o.).
if (c.portalCredentialsNotRequired) {
setUsernameType('not_required');
setSelectedStressfreiEmailId('');
} else 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, copySource, isEdit, isCopy, 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),
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
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: manuell, Stressfrei-Adresse oder Opt-out.
// Bei 'not_required' werden Username-Felder + Passwort explizit
// geleert und das Flag gesetzt so verschwindet die Cockpit-Warnung.
portalUsername: usernameType === 'manual' ? emptyToNull(data.portalUsername) : null,
stressfreiEmailId: usernameType === 'stressfrei' && selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : null,
portalCredentialsNotRequired: usernameType === 'not_required',
portalPassword: usernameType === 'not_required' ? undefined : (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 => {
const ac = (pn.areaCode || '').trim();
const sub = (pn.subscriber || '').trim();
// phoneNumber bleibt das kombinierte "Vorwahl Anschluss"-Feld
// für Listen/Suchen, areaCode/subscriber sind die getrennten Werte.
const combined = ac && sub ? `${ac} ${sub}` : (sub || ac);
return {
id: pn.id,
phoneNumber: combined,
areaCode: ac || undefined,
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,
mobileNetwork: emptyToNull(data.mobileNetwork),
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,
isEsim: sc.isEsim,
cardUser: emptyToNull(sc.cardUser),
})) : 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) || [];
// Aktuell im Dropdown gewählte Bankkarte/Ausweis für die Copy-Buttons
// neben dem Label (nur reiner Wert: IBAN bzw. Ausweisnummer).
const selectedBankCard = bankCards.find((c) => c.id.toString() === String(selectedBankCardId));
const selectedDocument = documents.find((d) => d.id.toString() === String(selectedDocumentId));
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>
{/* Sticky-Header: Zurück-Button + Titel + Kunde-Zeile bleiben beim
Scrollen oben stehen. Layout-Padding wird per -mx-8 px-8
überbrückt, damit der Balken bündig ist. */}
<div className="sticky top-[var(--app-banner-h,0px)] z-20 -mx-8 px-8 pt-4 pb-3 mb-6 bg-gray-100 border-b border-gray-200">
<div className="flex items-center gap-4 mb-2">
<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' : isCopy ? 'Neuer Vertrag (Kopie)' : 'Neuer Vertrag'}
</h1>
</div>
{isCopy && (
<p className="ml-12 mt-1 text-sm text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-1 inline-flex items-center gap-1">
<Copy className="w-4 h-4" />
Kopie eines bestehenden Vertrags wird als <strong>neuer eigenständiger Vertrag</strong> gespeichert.
Anbieter/Tarif, Preise, Kunden-/Vertragsnummern und Daten bitte prüfen. Portal-Benutzername
übernommen, Passwort ggf. neu setzen.
</p>
)}
{customer && (
<p className="text-gray-500 ml-12 flex items-center gap-1">
Kunde:{' '}
<Link
to={`/customers/${customer.id}`}
className="text-blue-600 hover:underline"
>
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
</Link>
<a
href={`/customers/${customer.id}`}
target="_blank"
rel="noopener noreferrer"
title="Kundenakte in neuem Tab öffnen"
aria-label="Kundenakte in neuem Tab öffnen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
type="button"
onClick={() => setShowCustomerInfo(true)}
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
aria-label="Kundendaten anzeigen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<Info className="w-4 h-4" />
</button>
</p>
)}
</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={
<LabelWithLink to="/settings/platforms" title="Vertriebsplattformen verwalten (neuer Tab)">
Vertriebsplattform
</LabelWithLink>
}
{...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) => {
const parts = buildContractLabelParts(c);
const dateHint = c.startDate ? ` ${formatDate(c.startDate)}` : '';
const suffix = parts.extras.length > 0 ? ` · ${parts.extras.join(' · ')}` : '';
const deact = c.status === 'DEACTIVATED' ? ' · deaktiviert' : '';
return {
value: c.id,
label: `${c.contractNumber} (${c.type}${dateHint})${suffix}${deact}`,
};
})}
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={
<LabelWithLink to={`/customers/${customerId}?tab=addresses`} title="Adressen des Kunden in neuem Tab öffnen">
Lieferadresse
</LabelWithLink>
}
{...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={
<LabelWithLink to={`/customers/${customerId}?tab=addresses`} title="Adressen des Kunden in neuem Tab öffnen">
Rechnungsadresse
</LabelWithLink>
}
{...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={
<span className="inline-flex items-center gap-1">
<LabelWithLink to={`/customers/${customerId}?tab=bankcards`} title="Bankkarten des Kunden in neuem Tab öffnen">
Bankkarte
</LabelWithLink>
{selectedBankCard && (
<CopyButton value={selectedBankCard.iban} title="IBAN kopieren" />
)}
</span>
}
{...register('bankCardId')}
options={bankCards.map((c) => {
// Beschreibung kompakt anhängen, damit man Karten am
// Kontext unterscheiden kann (z.B. "Geschäftskonto").
// Newlines flach machen, Länge cappen.
const desc = c.description
? c.description.replace(/\s+/g, ' ').trim().slice(0, 60)
: '';
return {
value: c.id,
label: `${c.iban} (${c.accountHolder})${desc ? ` ${desc}` : ''}`,
};
})}
/>
<Select
label={
<span className="inline-flex items-center gap-1">
<LabelWithLink to={`/customers/${customerId}?tab=documents`} title="Ausweise des Kunden in neuem Tab öffnen">
Ausweis
</LabelWithLink>
{selectedDocument && (
<CopyButton value={selectedDocument.documentNumber} title="Ausweisnummer kopieren" />
)}
</span>
}
{...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={
<LabelWithLink to="/settings/providers" title="Anbieter & Tarife verwalten (neuer Tab)">
Anbieter
</LabelWithLink>
}
{...register('providerId')}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
<Select
label={
<LabelWithLink to="/settings/providers" title="Anbieter & Tarife verwalten (neuer Tab)">
Tarif
</LabelWithLink>
}
{...register('tariffId')}
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
disabled={!selectedProviderId}
/>
{contractType === 'MOBILE' && (
<Select
label="Mobilfunknetz"
{...register('mobileNetwork')}
options={[
{ value: 'TELEKOM', label: 'Telekom' },
{ value: 'VODAFONE', label: 'Vodafone' },
{ value: 'TELEFONICA', label: 'Telefónica' },
]}
placeholder="Bitte auswählen"
/>
)}
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
<Input label="Vertragsnummer beim Anbieter" maxLength={100} {...register('contractNumberAtProvider')} />
<Input label="Auftragsnummer bei Vertriebsplattform" maxLength={100} {...register('orderNumberAtSalesPlatform')} />
<Input label="Kundennummer bei Vertriebsplattform" maxLength={100} {...register('customerNumberAtSalesPlatform')} />
<Input label="Vertragsnummer bei Vertriebsplattform" maxLength={100} {...register('contractNumberAtSalesPlatform')} />
<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>
{(() => {
// Aktiv kopierbaren Wert je nach Modus ermitteln:
// - Manuell: aktueller Eingabewert von portalUsername
// - Stressfrei: E-Mail der ausgewählten Stressfrei-Adresse
const manualUsername = (watch('portalUsername') as string) || '';
const selectedStressfreiEmail = selectedStressfreiEmailId
? stressfreiEmails.find((e: { id: number; email: string }) => e.id.toString() === selectedStressfreiEmailId)?.email
: '';
const copyValue = usernameType === 'manual'
? manualUsername.trim()
: (selectedStressfreiEmail || '');
return (
<label className="flex items-center gap-2 mb-2 text-sm font-medium text-gray-700">
<span>Portal Benutzername</span>
{copyValue && <CopyButton value={copyValue} title={`Benutzername "${copyValue}" in Zwischenablage kopieren`} />}
{customerId && (
<a
href={`/customers/${customerId}?tab=stressfrei`}
target="_blank"
rel="noopener noreferrer"
title={`${customerEmailLabel} Adresse beim Kunden anlegen (neuer Tab)`}
aria-label={`${customerEmailLabel} Adresse beim Kunden anlegen (neuer Tab)`}
className="text-gray-400 hover:text-blue-600 p-0.5 rounded"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</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')}
maxLength={100}
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' && (
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<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...'}
/>
</div>
{customerId && (
<div className="flex items-center gap-1 pt-1.5 flex-shrink-0">
<a
href={`/customers/${customerId}?tab=stressfrei`}
target="_blank"
rel="noopener noreferrer"
title={`${customerEmailLabel} Adressen des Kunden in neuem Tab öffnen`}
aria-label={`${customerEmailLabel} Adressen des Kunden in neuem Tab öffnen`}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<ExternalLink className="w-4 h-4" />
</a>
<a
href={`/customers/${customerId}?tab=emails`}
target="_blank"
rel="noopener noreferrer"
title="E-Mail-Postfach des Kunden in neuem Tab öffnen"
aria-label="E-Mail-Postfach des Kunden in neuem Tab öffnen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<Mail className="w-4 h-4" />
</a>
</div>
)}
</div>
)}
{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>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="usernameType"
checked={usernameType === 'not_required'}
onChange={() => {
setUsernameType('not_required');
setValue('portalUsername', '');
setValue('portalPassword', '');
setSelectedStressfreiEmailId('');
}}
className="text-blue-600"
/>
<span className="text-sm">Nicht möglich zu bekommen / nicht benötigt</span>
</label>
{usernameType === 'not_required' && (
<p className="text-xs text-gray-500">
Anbieter hat kein Portal oder Kunde bekommt keine Zugangsdaten. Die Warnung Portal-Zugangsdaten fehlen" im Cockpit wird für diesen Vertrag unterdrückt.
</p>
)}
</div>
</div>
{usernameType !== 'not_required' && (
<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={
<LabelWithLink to={`/customers/${customerId}?tab=meters`} title="Zähler des Kunden in neuem Tab öffnen">
Zähler
</LabelWithLink>
}
{...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-5 gap-3">
<Input
label="Vorwahl"
value={pn.areaCode}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].areaCode = e.target.value;
setPhoneNumbers(updated);
}}
placeholder="z.B. 030"
/>
<Input
label="Rufnummer"
value={pn.subscriber}
onChange={(e) => {
const updated = [...phoneNumbers];
updated[index].subscriber = e.target.value;
setPhoneNumbers(updated);
}}
placeholder="z.B. 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, {
areaCode: '',
subscriber: '',
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.isEsim}
onChange={(e) => {
const updated = [...simCards];
updated[index].isEsim = e.target.checked;
setSimCards(updated);
}}
className="rounded border-gray-300"
/>
eSIM
</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="mb-3">
<Input
label="Kartennutzer"
value={card.cardUser}
onChange={(e) => {
const updated = [...simCards];
updated[index].cardUser = e.target.value;
setSimCards(updated);
}}
placeholder="z.B. Mitarbeiter-/Kind-Name (optional, kann vom Vertragsinhaber abweichen)"
/>
</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
isEsim: false,
cardUser: '',
}]);
}}
>
<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>
{/* Sticky-Footer: Speichern/Abbrechen bleiben unten sichtbar,
damit man beim langen Formular nicht ganz nach unten scrollen
muss. -mx-8 px-8 -mb-8 zieht den Balken bündig an die
Layout-Padding-Kante. */}
<div className="sticky bottom-0 z-20 -mx-8 px-8 pt-3 pb-3 -mb-8 mt-6 bg-gray-100 border-t border-gray-200 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)} />
{/* Kunden-Schnellansicht */}
{customer && (
<CustomerInfoModal
customerId={customer.id}
open={showCustomerInfo}
onClose={() => setShowCustomerInfo(false)}
deliveryAddress={addresses.find((a) => String(a.id) === watch('addressId')) || null}
billingAddress={addresses.find((a) => String(a.id) === watch('billingAddressId')) || null}
contractStressfreiEmailId={selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : null}
/>
)}
</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>
);
}