From 5f06ed983003a41a80c05a92213b5f5313f20a93 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 8 Aug 2026 22:49:41 +0200 Subject: [PATCH] Fix Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal erweitern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit previousContractId an. Der Verlust passierte im Frontend - das Vorgaenger-Dropdown holt Vertraege ueber getAll, das DEACTIVATED standardmaessig ausblendet. Beim Bearbeiten des Folgevertrags war der deaktivierte Vorgaenger nicht als Option da -> Verknuepfung ging beim Speichern verloren. Fix: getAllContracts + Controller + contractApi.getAll um includeDeactivated erweitert; Vorgaenger-Dropdown nutzt es und markiert deaktivierte Vertraege mit '· deaktiviert'. Verifiziert (Flag inkludiert deaktivierte; Folgevertrag setzt previousContractId). 2) Kundendaten-Modal: zeigt jetzt zusaetzlich Lieferadresse + (falls abweichend) Rechnungsadresse des geoeffneten Vertrags, die Stressfrei- Adresse des Vertrags einzeln und darunter alle weiteren Stressfrei- Adressen des Kunden. CustomerInfoModal nimmt optionale Vertragskontext- Props; ContractDetail + ContractForm uebergeben sie. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/contract.controller.ts | 1 + backend/src/services/contract.service.ts | 11 ++- docs/todo.md | 17 +++++ .../contracts/CustomerInfoModal.tsx | 76 +++++++++++++++++-- .../src/pages/contracts/ContractDetail.tsx | 3 + frontend/src/pages/contracts/ContractForm.tsx | 11 ++- frontend/src/services/api.ts | 7 +- 7 files changed, 113 insertions(+), 13 deletions(-) diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index e7e4eb0b..be91a850 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -119,6 +119,7 @@ export async function getContracts(req: AuthRequest, res: Response): Promise = {}; @@ -28,11 +29,13 @@ export async function getAllContracts(filters: ContractFilters) { } if (type) where.type = type; - // Status-Filter: Deaktivierte Verträge standardmäßig ausblenden + // Status-Filter: Deaktivierte Verträge standardmäßig ausblenden – außer + // ein expliziter Status ist gesetzt oder includeDeactivated=true (z.B. für + // das Vorgängervertrag-Dropdown, das auch deaktivierte Verträge als + // Vorgänger zulassen muss). if (status) { where.status = status; - } else { - // Wenn kein Status-Filter gesetzt, alle außer DEACTIVATED anzeigen + } else if (!includeDeactivated) { where.status = { not: ContractStatus.DEACTIVATED }; } diff --git a/docs/todo.md b/docs/todo.md index 58a8d312..4a4f2c1c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07) + - **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit + `previousContractId` an – der Verlust passierte im Frontend: das + „Vorgänger-Vertrag"-Dropdown holte die Verträge über `getAll`, das + **DEACTIVATED standardmäßig ausblendet**. Beim Bearbeiten des Folgevertrags + war der (deaktivierte) Vorgänger daher nicht als Option vorhanden → beim + Speichern ging die Verknüpfung verloren. Fix: `getAllContracts` + + Controller + `contractApi.getAll` um `includeDeactivated` erweitert; das + Vorgänger-Dropdown nutzt es (`includeDeactivated: true`) und markiert + deaktivierte Verträge mit „· deaktiviert". Verifiziert. + - **Kundendaten-Modal (Schnellansicht):** zeigt jetzt zusätzlich die + **Lieferadresse** und (falls abweichend) **Rechnungsadresse** des geöffneten + Vertrags, die **Stressfrei-Adresse des Vertrags** (einzeln) und darunter + **alle weiteren Stressfrei-Adressen** des Kunden. `CustomerInfoModal` nimmt + optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId), + Aufrufe aus ContractDetail + ContractForm übergeben den Vertragskontext. + - [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07) - **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate` diff --git a/frontend/src/components/contracts/CustomerInfoModal.tsx b/frontend/src/components/contracts/CustomerInfoModal.tsx index 70f05b79..78a464cf 100644 --- a/frontend/src/components/contracts/CustomerInfoModal.tsx +++ b/frontend/src/components/contracts/CustomerInfoModal.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { X, RefreshCw, User } from 'lucide-react'; -import { customerApi } from '../../services/api'; +import { customerApi, stressfreiEmailApi } from '../../services/api'; +import type { Address } from '../../types'; import { CopyableValue } from '../ui/CopyButton'; import Button from '../ui/Button'; @@ -8,6 +9,17 @@ interface CustomerInfoModalProps { customerId: number; open: boolean; onClose: () => void; + // Optional: Kontext des geöffneten Vertrags. Wenn gesetzt, werden die + // Liefer-/Rechnungsadresse dieses Vertrags und seine Stressfrei-Adresse + // gesondert angezeigt. + deliveryAddress?: Address | null; + billingAddress?: Address | null; + contractStressfreiEmailId?: number | null; +} + +function formatAddress(a?: Address | null): string { + if (!a) return ''; + return `${a.street} ${a.houseNumber ?? ''}, ${a.postalCode} ${a.city}`.trim(); } /** @@ -15,7 +27,14 @@ interface CustomerInfoModalProps { * Vertragsdetail-Seite per Info-Icon neben dem Kundennamen geöffnet. * Jedes Feld hat einen Copy-Button rechts. Modal-only, schreibt nichts. */ -export default function CustomerInfoModal({ customerId, open, onClose }: CustomerInfoModalProps) { +export default function CustomerInfoModal({ + customerId, + open, + onClose, + deliveryAddress, + billingAddress, + contractStressfreiEmailId, +}: CustomerInfoModalProps) { const { data, isLoading } = useQuery({ queryKey: ['customer-info-modal', customerId], queryFn: () => customerApi.getById(customerId), @@ -23,6 +42,14 @@ export default function CustomerInfoModal({ customerId, open, onClose }: Custome staleTime: 30_000, }); + // Stressfrei-Wechseln-Adressen des Kunden (nur aktive). + const { data: stressfreiData } = useQuery({ + queryKey: ['customer-info-modal-stressfrei', customerId], + queryFn: () => stressfreiEmailApi.getByCustomer(customerId), + enabled: open, + staleTime: 30_000, + }); + if (!open) return null; const c = data?.data; @@ -32,9 +59,20 @@ export default function CustomerInfoModal({ customerId, open, onClose }: Custome : ''; const primaryAddress = c?.addresses?.find((a) => a.isDefault) || c?.addresses?.[0]; - const addressString = primaryAddress - ? `${primaryAddress.street} ${primaryAddress.houseNumber ?? ''}, ${primaryAddress.postalCode} ${primaryAddress.city}`.trim() - : ''; + const addressString = primaryAddress ? formatAddress(primaryAddress) : ''; + + const deliveryStr = formatAddress(deliveryAddress); + // Rechnungsadresse nur zeigen, wenn sie tatsächlich abweicht. + const billingStr = + billingAddress && billingAddress.id !== deliveryAddress?.id ? formatAddress(billingAddress) : ''; + + const stressfreiList = stressfreiData?.data ?? []; + const contractStressfrei = contractStressfreiEmailId + ? stressfreiList.find((s) => s.id === contractStressfreiEmailId) + : undefined; + const otherStressfrei = stressfreiList.filter((s) => s.id !== contractStressfreiEmailId); + + const hasContractContext = !!(deliveryStr || billingStr || contractStressfrei || otherStressfrei.length); const formatDate = (iso?: string) => { if (!iso) return ''; @@ -107,6 +145,34 @@ export default function CustomerInfoModal({ customerId, open, onClose }: Custome value={c.commercialRegisterNumber} /> )} + + {/* Vertragsbezogene Zusatzinfos */} + {hasContractContext && ( +
+ {deliveryStr && ( + + )} + {billingStr && ( + + )} + {contractStressfrei && ( + + )} + {otherStressfrei.length > 0 && ( +
+
Weitere Stressfrei-Adressen
+
+ {otherStressfrei.map((s) => ( + + ))} +
+
+ )} +
+ )} )} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index a3f03002..0c2aa94b 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -3387,6 +3387,9 @@ export default function ContractDetail() { customerId={c.customer.id} open={showCustomerInfo} onClose={() => setShowCustomerInfo(false)} + deliveryAddress={c.address} + billingAddress={c.billingAddress} + contractStressfreiEmailId={c.stressfreiEmailId} /> )} diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx index 0bee0f58..b62251be 100644 --- a/frontend/src/pages/contracts/ContractForm.tsx +++ b/frontend/src/pages/contracts/ContractForm.tsx @@ -153,7 +153,10 @@ export default function ContractForm() { // Fetch contracts for same customer (for predecessor selection) const { data: customerContractsData } = useQuery({ queryKey: ['customer-contracts-for-predecessor', customerId], - queryFn: () => contractApi.getAll({ customerId: parseInt(customerId), limit: 1000 }), + // 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, }); @@ -965,9 +968,10 @@ export default function ContractForm() { 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}`, + label: `${c.contractNumber} (${c.type}${dateHint})${suffix}${deact}`, }; })} placeholder="Keinen Vorgänger auswählen" @@ -1994,6 +1998,9 @@ export default function ContractForm() { 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} /> )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f6598197..e8da9102 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -908,8 +908,11 @@ export interface ContractTreeNode { } export const contractApi = { - getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number }) => { - const res = await api.get>('/contracts', { params }); + getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number; includeDeactivated?: boolean }) => { + const query = params?.includeDeactivated + ? { ...params, includeDeactivated: 'true' } + : params; + const res = await api.get>('/contracts', { params: query }); return res.data; }, getTreeForCustomer: async (customerId: number, includeDeactivated = false) => {