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:
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
|
||||||
|
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
|
||||||
|
Neu-Formular unter `/contracts/new?copyFrom=<id>&customerId=<cid>` mit
|
||||||
|
ALLEN Daten des Quellvertrags vorbefüllt. Beim Speichern entsteht ein
|
||||||
|
frischer, unabhängiger Vertrag (kein `previousContractId`-Link, kein VVL).
|
||||||
|
- Use-Case: neuer Mobilfunk-/o.ä. Vertrag ist meist fast identisch – nur
|
||||||
|
Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif ändern sich.
|
||||||
|
- Geleert im Kopier-Modus (Integrität / Eindeutigkeit): Status→DRAFT,
|
||||||
|
Vorgänger-Link, alle Datumsfelder (Start/Ende/Installation/Kündigungs-
|
||||||
|
bestätigung), Kunden-/Vertragsnummer beim Anbieter + Plattform-Nummern,
|
||||||
|
Portal-Benutzer/Passwort. **Nicht** übernommen: SIM-Karten & Rufnummern
|
||||||
|
(hardware-/vertragsspezifisch, eindeutige Nummern). Rest (Anbieter/Tarif,
|
||||||
|
Preise, alle Detailfelder, Notizen) bleibt als Vorlage.
|
||||||
|
- Rein Frontend (`ContractForm` copy-Mode + Button in `ContractDetail`);
|
||||||
|
kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner
|
||||||
|
„Kopie … neuer eigenständiger Vertrag" im Formular.
|
||||||
|
|
||||||
- [~] **🖱️ PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN** (2026-07-30)
|
- [~] **🖱️ PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN** (2026-07-30)
|
||||||
- Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer
|
- Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer
|
||||||
ziehen. Über mehrere Iterationen durchgespielt (Chromium-`DownloadURL`
|
ziehen. Über mehrere Iterationen durchgespielt (Chromium-`DownloadURL`
|
||||||
|
|||||||
@@ -1956,6 +1956,18 @@ export default function ContractDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('contracts:create') && (
|
||||||
|
<Link
|
||||||
|
to={`/contracts/new?copyFrom=${contractId}&customerId=${c.customerId}`}
|
||||||
|
state={pushHistory(currentPath, location.state)}
|
||||||
|
title="Alle Daten dieses Vertrags in einen neuen, eigenständigen Vertrag übernehmen"
|
||||||
|
>
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Kopieren
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{hasPermission('contracts:update') && (
|
{hasPermission('contracts:update') && (
|
||||||
<Link to={`/contracts/${id}/edit`} state={pushHistory(currentPath, location.state)}>
|
<Link to={`/contracts/${id}/edit`} state={pushHistory(currentPath, location.state)}>
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { buildContractLabelParts } from '../../utils/contractLabel';
|
|||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
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
|
// Kleine Helper-Komponente: Label + Mini-Link im neuen Tab. Wird neben
|
||||||
// Select-Feldern eingesetzt, deren Stammdaten an anderer Stelle gepflegt
|
// Select-Feldern eingesetzt, deren Stammdaten an anderer Stelle gepflegt
|
||||||
@@ -100,6 +100,12 @@ export default function ContractForm() {
|
|||||||
const { customerEmailLabel } = useProviderSettings();
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
|
|
||||||
const preselectedCustomerId = searchParams.get('customerId');
|
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>({
|
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<any>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -124,6 +130,13 @@ export default function ContractForm() {
|
|||||||
enabled: isEdit,
|
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
|
// Fetch customers for dropdown
|
||||||
const { data: customersData } = useQuery({
|
const { data: customersData } = useQuery({
|
||||||
queryKey: ['customers-all'],
|
queryKey: ['customers-all'],
|
||||||
@@ -286,16 +299,19 @@ export default function ContractForm() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit && contract?.data && !customerId) {
|
if (isEdit && contract?.data && !customerId) {
|
||||||
setValue('customerId', contract.data.customerId.toString());
|
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
|
// Reset form when contract data AND ALL dropdown data are loaded
|
||||||
// This ensures dropdowns can properly display the selected values
|
// This ensures dropdowns can properly display the selected values
|
||||||
// We need customerDetail for addresses/bankCards/documents and providersData for tariffs
|
// We need customerDetail for addresses/bankCards/documents and providersData for tariffs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contract?.data && platformsData?.data && contractCategoriesData?.data && providersData?.data && customerDetail?.data) {
|
const src = isEdit ? contract?.data : (isCopy ? copySource?.data : null);
|
||||||
const c = contract.data;
|
if (src && platformsData?.data && contractCategoriesData?.data && providersData?.data && customerDetail?.data) {
|
||||||
reset({
|
const c = src;
|
||||||
|
const values: any = {
|
||||||
customerId: c.customerId.toString(),
|
customerId: c.customerId.toString(),
|
||||||
type: c.type,
|
type: c.type,
|
||||||
status: c.status,
|
status: c.status,
|
||||||
@@ -383,10 +399,41 @@ export default function ContractForm() {
|
|||||||
previousProviderId: c.previousProviderId?.toString() || '',
|
previousProviderId: c.previousProviderId?.toString() || '',
|
||||||
previousCustomerNumber: c.previousCustomerNumber || '',
|
previousCustomerNumber: c.previousCustomerNumber || '',
|
||||||
previousContractNumber: c.previousContractNumber || '',
|
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
|
// 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 => ({
|
setSimCards(c.mobileDetails.simCards.map(sc => ({
|
||||||
id: sc.id,
|
id: sc.id,
|
||||||
phoneNumber: sc.phoneNumber || '',
|
phoneNumber: sc.phoneNumber || '',
|
||||||
@@ -405,7 +452,7 @@ export default function ContractForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load phoneNumbers if available (Internet contracts)
|
// 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 => {
|
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => {
|
||||||
// Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
|
// Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
|
||||||
// heuristisch splitten (dann sollte der User das Ergebnis prüfen
|
// heuristisch splitten (dann sollte der User das Ergebnis prüfen
|
||||||
@@ -448,8 +495,11 @@ export default function ContractForm() {
|
|||||||
// Portal-Benutzername-Typ initialisieren.
|
// Portal-Benutzername-Typ initialisieren.
|
||||||
// Opt-out („nicht benötigt") gewinnt vor Stressfrei/Manual, damit ein
|
// Opt-out („nicht benötigt") gewinnt vor Stressfrei/Manual, damit ein
|
||||||
// versehentlich noch alter portalUsername den Radio-Zustand nicht
|
// versehentlich noch alter portalUsername den Radio-Zustand nicht
|
||||||
// überstimmt.
|
// überstimmt. Im Kopier-Modus immer frisch (manuell, leer).
|
||||||
if (c.portalCredentialsNotRequired) {
|
if (isCopy) {
|
||||||
|
setUsernameType('manual');
|
||||||
|
setSelectedStressfreiEmailId('');
|
||||||
|
} else if (c.portalCredentialsNotRequired) {
|
||||||
setUsernameType('not_required');
|
setUsernameType('not_required');
|
||||||
setSelectedStressfreiEmailId('');
|
setSelectedStressfreiEmailId('');
|
||||||
} else if (c.stressfreiEmailId) {
|
} else if (c.stressfreiEmailId) {
|
||||||
@@ -463,7 +513,7 @@ export default function ContractForm() {
|
|||||||
// Mark contract as loaded so provider change detection works correctly
|
// Mark contract as loaded so provider change detection works correctly
|
||||||
setIsContractLoaded(true);
|
setIsContractLoaded(true);
|
||||||
}
|
}
|
||||||
}, [contract, reset, platformsData, contractCategoriesData, providersData, customerDetail]);
|
}, [contract, copySource, isEdit, isCopy, reset, platformsData, contractCategoriesData, providersData, customerDetail]);
|
||||||
|
|
||||||
// Watch für Enddatum-Berechnung
|
// Watch für Enddatum-Berechnung
|
||||||
const startDate = watch('startDate');
|
const startDate = watch('startDate');
|
||||||
@@ -806,9 +856,16 @@ export default function ContractForm() {
|
|||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">
|
||||||
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
{isEdit ? 'Vertrag bearbeiten' : isCopy ? 'Neuer Vertrag (Kopie)' : 'Neuer Vertrag'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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 && (
|
{customer && (
|
||||||
<p className="text-gray-500 ml-12 flex items-center gap-1">
|
<p className="text-gray-500 ml-12 flex items-center gap-1">
|
||||||
Kunde:{' '}
|
Kunde:{' '}
|
||||||
|
|||||||
Reference in New Issue
Block a user