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:
@@ -119,6 +119,7 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
|||||||
search: search as string,
|
search: search as string,
|
||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
|
includeDeactivated: includeDeactivated === 'true',
|
||||||
});
|
});
|
||||||
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
|
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
|
||||||
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
|
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ export interface ContractFilters {
|
|||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
includeDeactivated?: boolean; // wenn true: auch DEACTIVATED-Verträge listen
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllContracts(filters: ContractFilters) {
|
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 { skip, take } = paginate(page, limit);
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
@@ -28,11 +29,13 @@ export async function getAllContracts(filters: ContractFilters) {
|
|||||||
}
|
}
|
||||||
if (type) where.type = type;
|
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) {
|
if (status) {
|
||||||
where.status = status;
|
where.status = status;
|
||||||
} else {
|
} else if (!includeDeactivated) {
|
||||||
// Wenn kein Status-Filter gesetzt, alle außer DEACTIVATED anzeigen
|
|
||||||
where.status = { not: ContractStatus.DEACTIVATED };
|
where.status = { not: ContractStatus.DEACTIVATED };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07)
|
||||||
- **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
|
- **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
|
||||||
Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate`
|
Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { X, RefreshCw, User } from 'lucide-react';
|
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 { CopyableValue } from '../ui/CopyButton';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
@@ -8,6 +9,17 @@ interface CustomerInfoModalProps {
|
|||||||
customerId: number;
|
customerId: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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.
|
* Vertragsdetail-Seite per Info-Icon neben dem Kundennamen geöffnet.
|
||||||
* Jedes Feld hat einen Copy-Button rechts. Modal-only, schreibt nichts.
|
* 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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['customer-info-modal', customerId],
|
queryKey: ['customer-info-modal', customerId],
|
||||||
queryFn: () => customerApi.getById(customerId),
|
queryFn: () => customerApi.getById(customerId),
|
||||||
@@ -23,6 +42,14 @@ export default function CustomerInfoModal({ customerId, open, onClose }: Custome
|
|||||||
staleTime: 30_000,
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
const c = data?.data;
|
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 primaryAddress = c?.addresses?.find((a) => a.isDefault) || c?.addresses?.[0];
|
||||||
const addressString = primaryAddress
|
const addressString = primaryAddress ? formatAddress(primaryAddress) : '';
|
||||||
? `${primaryAddress.street} ${primaryAddress.houseNumber ?? ''}, ${primaryAddress.postalCode} ${primaryAddress.city}`.trim()
|
|
||||||
: '';
|
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) => {
|
const formatDate = (iso?: string) => {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -107,6 +145,34 @@ export default function CustomerInfoModal({ customerId, open, onClose }: Custome
|
|||||||
value={c.commercialRegisterNumber}
|
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>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3387,6 +3387,9 @@ export default function ContractDetail() {
|
|||||||
customerId={c.customer.id}
|
customerId={c.customer.id}
|
||||||
open={showCustomerInfo}
|
open={showCustomerInfo}
|
||||||
onClose={() => setShowCustomerInfo(false)}
|
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)
|
// Fetch contracts for same customer (for predecessor selection)
|
||||||
const { data: customerContractsData } = useQuery({
|
const { data: customerContractsData } = useQuery({
|
||||||
queryKey: ['customer-contracts-for-predecessor', customerId],
|
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,
|
enabled: !!customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -965,9 +968,10 @@ export default function ContractForm() {
|
|||||||
const parts = buildContractLabelParts(c);
|
const parts = buildContractLabelParts(c);
|
||||||
const dateHint = c.startDate ? ` – ${formatDate(c.startDate)}` : '';
|
const dateHint = c.startDate ? ` – ${formatDate(c.startDate)}` : '';
|
||||||
const suffix = parts.extras.length > 0 ? ` · ${parts.extras.join(' · ')}` : '';
|
const suffix = parts.extras.length > 0 ? ` · ${parts.extras.join(' · ')}` : '';
|
||||||
|
const deact = c.status === 'DEACTIVATED' ? ' · deaktiviert' : '';
|
||||||
return {
|
return {
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: `${c.contractNumber} (${c.type}${dateHint})${suffix}`,
|
label: `${c.contractNumber} (${c.type}${dateHint})${suffix}${deact}`,
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
placeholder="Keinen Vorgänger auswählen"
|
placeholder="Keinen Vorgänger auswählen"
|
||||||
@@ -1994,6 +1998,9 @@ export default function ContractForm() {
|
|||||||
customerId={customer.id}
|
customerId={customer.id}
|
||||||
open={showCustomerInfo}
|
open={showCustomerInfo}
|
||||||
onClose={() => setShowCustomerInfo(false)}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -908,8 +908,11 @@ export interface ContractTreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const contractApi = {
|
export const contractApi = {
|
||||||
getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number }) => {
|
getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number; includeDeactivated?: boolean }) => {
|
||||||
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params });
|
const query = params?.includeDeactivated
|
||||||
|
? { ...params, includeDeactivated: 'true' }
|
||||||
|
: params;
|
||||||
|
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params: query });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
getTreeForCustomer: async (customerId: number, includeDeactivated = false) => {
|
getTreeForCustomer: async (customerId: number, includeDeactivated = false) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user