Fix Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal erweitern

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 22:49:41 +02:00
co-authored by Claude Opus 4.8
parent a6b1dac606
commit 5f06ed9830
7 changed files with 113 additions and 13 deletions
@@ -119,6 +119,7 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
search: search as string,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
includeDeactivated: includeDeactivated === 'true',
});
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
+7 -4
View File
@@ -12,10 +12,11 @@ export interface ContractFilters {
search?: string;
page?: number;
limit?: number;
includeDeactivated?: boolean; // wenn true: auch DEACTIVATED-Verträge listen
}
export async function getAllContracts(filters: ContractFilters) {
const { customerId, customerIds, type, status, search, page = 1, limit = 20 } = filters;
const { customerId, customerIds, type, status, search, page = 1, limit = 20, includeDeactivated = false } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
@@ -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 };
}
+17
View File
@@ -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`
@@ -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 && (
<div className="pt-3 mt-3 border-t border-gray-200 space-y-3">
{deliveryStr && (
<CopyableValue label="Lieferadresse (Vertrag)" value={deliveryStr} />
)}
{billingStr && (
<CopyableValue label="Rechnungsadresse (Vertrag)" value={billingStr} />
)}
{contractStressfrei && (
<CopyableValue
label="Stressfrei-Adresse (Vertrag)"
value={contractStressfrei.email}
/>
)}
{otherStressfrei.length > 0 && (
<div>
<dt className="text-sm text-gray-500 mb-1">Weitere Stressfrei-Adressen</dt>
<dd className="space-y-1">
{otherStressfrei.map((s) => (
<CopyableValue key={s.id} value={s.email} />
))}
</dd>
</div>
)}
</div>
)}
</dl>
)}
</div>
@@ -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}
/>
)}
@@ -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}
/>
)}
</div>
+5 -2
View File
@@ -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<ApiResponse<Contract[]>>('/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<ApiResponse<Contract[]>>('/contracts', { params: query });
return res.data;
},
getTreeForCustomer: async (customerId: number, includeDeactivated = false) => {