Vertrag kopieren: neuen eigenstaendigen Vertrag aus Vorlage anlegen

Kopieren-Button in der Vertragsansicht oeffnet das Neu-Formular unter
/contracts/new?copyFrom=<id> mit allen Daten des Quellvertrags
vorbefuellt. Beim Speichern entsteht ein frischer, unabhaengiger
Vertrag (kein previousContractId-Link, kein VVL).

Use-Case: neuer Mobilfunk-/o.ae. Vertrag ist meist fast identisch;
nur Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif aendern
sich - der Rest bleibt gleich.

Im Kopier-Modus geleert (Integritaet/Eindeutigkeit): Status->DRAFT,
Vorgaenger-Link, alle Datumsfelder, Kunden-/Vertragsnummer beim
Anbieter + Plattform-Nummern, Portal-Zugang. SIM-Karten & Rufnummern
werden nicht uebernommen. Rest (Anbieter/Tarif/Preise/Detailfelder/
Notizen) bleibt als Vorlage.

Rein Frontend (ContractForm copy-Mode + Button in ContractDetail),
kein neuer Endpoint - nutzt bestehendes Create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:44:49 +02:00
co-authored by Claude Opus 4.8
parent 5bbb7dfffe
commit a2ed0e94f1
3 changed files with 98 additions and 12 deletions
+69 -12
View File
@@ -15,7 +15,7 @@ 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 } from 'lucide-react';
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
@@ -100,6 +100,12 @@ export default function ContractForm() {
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: {
@@ -124,6 +130,13 @@ export default function ContractForm() {
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'],
@@ -286,16 +299,19 @@ export default function ContractForm() {
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, customerId, setValue]);
}, [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(() => {
if (contract?.data && platformsData?.data && contractCategoriesData?.data && providersData?.data && customerDetail?.data) {
const c = contract.data;
reset({
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,
@@ -383,10 +399,41 @@ export default function ContractForm() {
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,
// Eindeutige Kunden-/Vertragsnummern beim Anbieter & Plattform
customerNumberAtProvider: '',
contractNumberAtProvider: '',
orderNumberAtSalesPlatform: '',
customerNumberAtSalesPlatform: '',
contractNumberAtSalesPlatform: '',
// Portal-Zugang gehört zum konkreten Vertrag → frisch
portalUsername: '',
});
}
reset(values);
// SIM-Karten & Rufnummern sind hardware-/vertragsspezifisch (eindeutige
// Karten-/Telefonnummern) → im Kopier-Modus NICHT übernehmen.
// Load simCards if available
if (c.mobileDetails?.simCards && c.mobileDetails.simCards.length > 0) {
if (!isCopy && c.mobileDetails?.simCards && c.mobileDetails.simCards.length > 0) {
setSimCards(c.mobileDetails.simCards.map(sc => ({
id: sc.id,
phoneNumber: sc.phoneNumber || '',
@@ -405,7 +452,7 @@ export default function ContractForm() {
}
// Load phoneNumbers if available (Internet contracts)
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
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
@@ -448,8 +495,11 @@ export default function ContractForm() {
// Portal-Benutzername-Typ initialisieren.
// Opt-out („nicht benötigt") gewinnt vor Stressfrei/Manual, damit ein
// versehentlich noch alter portalUsername den Radio-Zustand nicht
// überstimmt.
if (c.portalCredentialsNotRequired) {
// überstimmt. Im Kopier-Modus immer frisch (manuell, leer).
if (isCopy) {
setUsernameType('manual');
setSelectedStressfreiEmailId('');
} else if (c.portalCredentialsNotRequired) {
setUsernameType('not_required');
setSelectedStressfreiEmailId('');
} else if (c.stressfreiEmailId) {
@@ -463,7 +513,7 @@ export default function ContractForm() {
// Mark contract as loaded so provider change detection works correctly
setIsContractLoaded(true);
}
}, [contract, reset, platformsData, contractCategoriesData, providersData, customerDetail]);
}, [contract, copySource, isEdit, isCopy, reset, platformsData, contractCategoriesData, providersData, customerDetail]);
// Watch für Enddatum-Berechnung
const startDate = watch('startDate');
@@ -806,9 +856,16 @@ export default function ContractForm() {
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
{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.
</p>
)}
{customer && (
<p className="text-gray-500 ml-12 flex items-center gap-1">
Kunde:{' '}