contracts: VVL (Vertragsverlängerung) als Split-Button neben Folgevertrag

VVL = Vertragsverlängerung beim selben Anbieter (vs. Folgevertrag = i.d.R.
Anbieterwechsel).

Im Gegensatz zu createFollowUpContract wird ALLES kopiert:
- Provider, Tarif, Portal-Username/Passwort (verschlüsselt)
- Preise (basePrice/unitPrice/bonus etc.)
- Notes, Commission, Internet-Zugangsdaten, SIP-Daten, SIM-PINs
- ContractDocuments (1:1, gleiche Datei-Referenz)
- Detail-Tabellen (Energy/Internet/Mobile/TV/CarInsurance) komplett

Berechnet:
- newStartDate = oldStartDate + Vertragslaufzeit (Monate aus
  ContractDuration.code/description geparsed: "24M" / "24 Monate" / "2J")
- newEndDate = newStartDate + Laufzeit
- status = DRAFT (User bestätigt manuell)

NICHT kopiert:
- documentType "Auftragsformular" (das wird neu unterschrieben)
- cancellation*-Felder (alter Cancel-Flow nicht relevant)

Frontend:
- Split-Button: Hauptaktion "Folgevertrag anlegen" + ChevronDown-Pfeil
- Dropdown: "VVL anlegen" mit Bestätigungs-Modal
- Modal zeigt Vorhersage des neuen Startdatums (alter Start +
  Vertragslaufzeit als Hinweis)

History-Einträge wie bei Folgevertrag, mit eigenem VVL-Wording.
Doppel-Schutz: maximal 1 Folge-/VVL-Vertrag pro Vorgänger.

Live-verifiziert:
- Contract #17 (FIBER, 2026-05-01, 24M) → VVL mit Start 2028-05-01 ✓
- Provider/Tarif/Preise/Credentials 1:1 übernommen
- 2 Dokumente kopiert (außer Auftragsformular)
- History-Einträge in beiden Verträgen vorhanden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 09:12:39 +02:00
parent e763952a84
commit 77602bb4ac
6 changed files with 447 additions and 8 deletions
+111 -8
View File
@@ -1514,6 +1514,26 @@ export default function ContractDetail() {
},
});
// VVL = Vertragsverlängerung beim selben Anbieter (alle Daten 1:1 + Datum berechnet)
const renewalMutation = useMutation({
mutationFn: () => contractApi.createRenewal(contractId),
onSuccess: (data) => {
if (data.data) {
navigate(`/contracts/${data.data.id}/edit`);
} else {
alert('VVL wurde erstellt, aber keine ID zurückgegeben');
}
},
onError: (error) => {
console.error('VVL Fehler:', error);
alert(`Fehler beim Erstellen der VVL: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
},
});
// Dropdown-Toggle für VVL
const [showFollowUpMenu, setShowFollowUpMenu] = useState(false);
const [showVvlConfirm, setShowVvlConfirm] = useState(false);
// Un-Snooze Mutation
const unsnoozeMutation = useMutation({
mutationFn: () => contractApi.snooze(contractId, {}),
@@ -1756,14 +1776,50 @@ export default function ContractDetail() {
</Link>
)}
{hasPermission('contracts:create') && !c.followUpContract && (
<Button
variant="secondary"
onClick={() => setShowFollowUpConfirm(true)}
disabled={followUpMutation.isPending}
>
<Copy className="w-4 h-4 mr-2" />
{followUpMutation.isPending ? 'Erstelle...' : 'Folgevertrag anlegen'}
</Button>
<div className="relative inline-flex">
{/* Hauptaktion: Folgevertrag anlegen */}
<Button
variant="secondary"
onClick={() => setShowFollowUpConfirm(true)}
disabled={followUpMutation.isPending || renewalMutation.isPending}
className="!rounded-r-none !border-r-0"
>
<Copy className="w-4 h-4 mr-2" />
{followUpMutation.isPending ? 'Erstelle...' : 'Folgevertrag anlegen'}
</Button>
{/* Dropdown-Pfeil für VVL */}
<Button
variant="secondary"
onClick={() => setShowFollowUpMenu(!showFollowUpMenu)}
disabled={followUpMutation.isPending || renewalMutation.isPending}
className="!rounded-l-none !px-2"
title="Weitere Optionen"
>
<ChevronDown className="w-4 h-4" />
</Button>
{showFollowUpMenu && (
<>
{/* Click-outside-Overlay */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowFollowUpMenu(false)}
/>
<div className="absolute top-full right-0 mt-1 z-20 w-56 bg-white border border-gray-200 rounded-lg shadow-lg py-1">
<button
type="button"
onClick={() => {
setShowFollowUpMenu(false);
setShowVvlConfirm(true);
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 flex items-center gap-2"
>
<Copy className="w-4 h-4 text-gray-500" />
VVL anlegen
</button>
</div>
</>
)}
</div>
)}
{c.followUpContract && (
<Link to={`/contracts/${c.followUpContract.id}`}>
@@ -3077,6 +3133,53 @@ export default function ContractDetail() {
</div>
</Modal>
{/* VVL Bestätigung */}
<Modal
isOpen={showVvlConfirm}
onClose={() => setShowVvlConfirm(false)}
title="Vertragsverlängerung (VVL) anlegen"
size="sm"
>
<div className="space-y-4">
<p className="text-gray-700">
Möchten Sie eine Vertragsverlängerung für diesen Vertrag anlegen?
</p>
<p className="text-sm text-gray-500">
Alle Daten werden 1:1 übernommen (auch Provider, Tarif, Portal-
Zugang, Preise und Vertragsdokumente). Das Startdatum wird auf
den nächsten Laufzeit-Beginn berechnet (altes Startdatum +
Vertragslaufzeit). Das <strong>Auftragsdokument</strong> wird
<strong> nicht </strong> mitkopiert das ist die neue,
unterschriebene VVL, die Sie selbst hochladen.
</p>
{c.startDate && c.contractDuration?.description && (
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
Vorhersage: alter Beginn{' '}
<strong>{new Date(c.startDate).toLocaleDateString('de-DE')}</strong> +{' '}
<strong>{c.contractDuration.description}</strong>
{' = '}neuer VVL-Beginn (siehe danach im Vertrag)
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
onClick={() => setShowVvlConfirm(false)}
>
Nein
</Button>
<Button
onClick={() => {
setShowVvlConfirm(false);
renewalMutation.mutate();
}}
disabled={renewalMutation.isPending}
>
{renewalMutation.isPending ? 'Erstelle...' : 'Ja, VVL anlegen'}
</Button>
</div>
</div>
</Modal>
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
+4
View File
@@ -657,6 +657,10 @@ export const contractApi = {
const res = await api.post<ApiResponse<Contract>>(`/contracts/${id}/follow-up`);
return res.data;
},
createRenewal: async (id: number) => {
const res = await api.post<ApiResponse<Contract>>(`/contracts/${id}/renewal`);
return res.data;
},
getPassword: async (id: number) => {
const res = await api.get<ApiResponse<{ password: string }>>(`/contracts/${id}/password`);
return res.data;