Vertragslisten: Karteninhaber + Mobilfunknetz + Kuendigung (rot)

Beide Vertragslisten (Kundenakte-Baum + Hauptmenue /contracts) zeigen bei
Mobilfunkvertraegen mit Rufnummer jetzt zusaetzlich Karteninhaber
(SimCard.cardUser der angezeigten SIM, nur wenn gesetzt) und Netz
(mobileNetwork -> Telekom/Vodafone/Telefonica (o2)) inline. Vertraege mit
Kuendigungsbestaetigung (cancellationConfirmationDate) bekommen eine eigene
Zeile in roter, fetter Schrift.

Backend: getContractTreeForCustomer + getAllContracts selektieren nun
mobileNetwork + cardUser (Tree zusaetzlich cancellationConfirmationDate).
Shared-Helper getContractTypeInfo um cardUser/network + mobileNetworkLabel
erweitert. Beide Listen rendern ueber den Tree-Endpoint.

Verifiziert: tsc + vite build gruen, Tree-Service liefert die Felder,
Helper-Mapping getestet (Vodafone/Telekom/o2, leerer Karteninhaber ausgelassen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:18:24 +02:00
co-authored by Claude Opus 4.8
parent 8e46dbbfed
commit c5cf2c7416
6 changed files with 72 additions and 7 deletions
+8 -3
View File
@@ -105,7 +105,8 @@ export async function getAllContracts(filters: ContractFilters) {
mobileDetails: { mobileDetails: {
select: { select: {
phoneNumber: true, phoneNumber: true,
simCards: { select: { phoneNumber: true, isMain: true } }, mobileNetwork: true,
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
}, },
}, },
// 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall // 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall
@@ -1223,6 +1224,7 @@ export interface ContractTreeNode {
status: ContractStatus; status: ContractStatus;
startDate: Date | null; startDate: Date | null;
endDate: Date | null; endDate: Date | null;
cancellationConfirmationDate: Date | null;
providerName: string | null; providerName: string | null;
tariffName: string | null; tariffName: string | null;
previousContractId: number | null; previousContractId: number | null;
@@ -1233,7 +1235,8 @@ export interface ContractTreeNode {
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null; address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
mobileDetails?: { mobileDetails?: {
phoneNumber: string | null; phoneNumber: string | null;
simCards: { phoneNumber: string | null; isMain: boolean }[]; mobileNetwork: string | null;
simCards: { phoneNumber: string | null; isMain: boolean; cardUser: string | null }[];
} | null; } | null;
carInsuranceDetails?: { licensePlate: string | null } | null; carInsuranceDetails?: { licensePlate: string | null } | null;
}; };
@@ -1268,6 +1271,7 @@ export async function getContractTreeForCustomer(
status: true, status: true,
startDate: true, startDate: true,
endDate: true, endDate: true,
cancellationConfirmationDate: true,
providerName: true, providerName: true,
tariffName: true, tariffName: true,
previousContractId: true, previousContractId: true,
@@ -1279,7 +1283,8 @@ export async function getContractTreeForCustomer(
mobileDetails: { mobileDetails: {
select: { select: {
phoneNumber: true, phoneNumber: true,
simCards: { select: { phoneNumber: true, isMain: true } }, mobileNetwork: true,
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
}, },
}, },
carInsuranceDetails: { select: { licensePlate: true } }, carInsuranceDetails: { select: { licensePlate: true } },
+13
View File
@@ -97,6 +97,19 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **📇 Vertragslisten: Karteninhaber + Mobilfunknetz + Kündigung (rot)** (2026-08-13)
- In **beiden** Vertragslisten (Kundenakte-Baum + Hauptmenü `/contracts`): bei
Mobilfunkverträgen mit Rufnummer zusätzlich **Karteninhaber** (`SimCard.cardUser`
der angezeigten SIM, nur wenn gesetzt) und **Netz** (`mobileNetwork`
Telekom/Vodafone/Telefónica (o2)) inline hinter der Rufnummer.
- Verträge mit **Kündigungsbestätigung**: `cancellationConfirmationDate` als
eigene Zeile in **roter, fetter Schrift** („Kündigungsbestätigung zum …").
- Backend: `getContractTreeForCustomer` + `getAllContracts` liefern jetzt
`mobileNetwork` + `cardUser` (Tree zusätzlich `cancellationConfirmationDate`;
Flat-Liste hat Scalars ohnehin via include). Beide Listen rendern über den
Tree-Endpoint. Shared-Helper `getContractTypeInfo` um `cardUser`/`network`
erweitert (+ `mobileNetworkLabel`).
- [x] **🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148)** (2026-08-12) - [x] **🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148)** (2026-08-12)
- **Backstop:** Neuer genereller Limiter auf ALLE `/api`-Requests - **Backstop:** Neuer genereller Limiter auf ALLE `/api`-Requests
(`apiBackstopRateLimiter`, in `index.ts` vor den Routern). Vorher gab es (`apiBackstopRateLimiter`, in `index.ts` vor den Routern). Vorher gab es
@@ -318,6 +318,12 @@ export default function ContractList() {
return typeInfo ? ( return typeInfo ? (
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}> <p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value} <span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
{typeInfo.cardUser && (
<> · <span className="font-medium text-gray-700">Karteninhaber:</span> {typeInfo.cardUser}</>
)}
{typeInfo.network && (
<> · <span className="font-medium text-gray-700">Netz:</span> {typeInfo.network}</>
)}
</p> </p>
) : null; ) : null;
})()} })()}
@@ -328,6 +334,11 @@ export default function ContractList() {
` | Ende: ${formatDate(contract.endDate)}`} ` | Ende: ${formatDate(contract.endDate)}`}
</p> </p>
)} )}
{contract.cancellationConfirmationDate && (
<p className={`text-sm font-semibold text-red-600 ${isPredecessor ? 'ml-6' : ''}`}>
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
</p>
)}
</div> </div>
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */} {/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
@@ -1882,6 +1882,12 @@ function ContractsTab({
return typeInfo ? ( return typeInfo ? (
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}> <p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value} <span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
{typeInfo.cardUser && (
<> · <span className="font-medium text-gray-700">Karteninhaber:</span> {typeInfo.cardUser}</>
)}
{typeInfo.network && (
<> · <span className="font-medium text-gray-700">Netz:</span> {typeInfo.network}</>
)}
</p> </p>
) : null; ) : null;
})()} })()}
@@ -1892,6 +1898,11 @@ function ContractsTab({
` | Ende: ${formatDate(contract.endDate)}`} ` | Ende: ${formatDate(contract.endDate)}`}
</p> </p>
)} )}
{contract.cancellationConfirmationDate && (
<p className={`text-sm font-semibold text-red-600 ${isPredecessor ? 'ml-6' : ''}`}>
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
</p>
)}
</div> </div>
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */} {/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
+3 -1
View File
@@ -913,6 +913,7 @@ export interface ContractTreeNodeContract {
status: string; status: string;
startDate: string | null; startDate: string | null;
endDate: string | null; endDate: string | null;
cancellationConfirmationDate?: string | null;
providerName: string | null; providerName: string | null;
tariffName: string | null; tariffName: string | null;
previousContractId: number | null; previousContractId: number | null;
@@ -923,7 +924,8 @@ export interface ContractTreeNodeContract {
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null; address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
mobileDetails?: { mobileDetails?: {
phoneNumber: string | null; phoneNumber: string | null;
simCards: { phoneNumber: string | null; isMain: boolean }[]; mobileNetwork?: string | null;
simCards: { phoneNumber: string | null; isMain: boolean; cardUser?: string | null }[];
} | null; } | null;
carInsuranceDetails?: { licensePlate: string | null } | null; carInsuranceDetails?: { licensePlate: string | null } | null;
} }
+26 -3
View File
@@ -3,7 +3,8 @@ interface ContractInfoData {
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null; address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
mobileDetails?: { mobileDetails?: {
phoneNumber: string | null; phoneNumber: string | null;
simCards: { phoneNumber: string | null; isMain: boolean }[]; mobileNetwork?: string | null;
simCards: { phoneNumber: string | null; isMain: boolean; cardUser?: string | null }[];
} | null; } | null;
carInsuranceDetails?: { licensePlate: string | null } | null; carInsuranceDetails?: { licensePlate: string | null } | null;
} }
@@ -11,6 +12,21 @@ interface ContractInfoData {
export interface ContractTypeInfo { export interface ContractTypeInfo {
label: string; label: string;
value: string; value: string;
// Nur bei Mobilfunk zusätzlich befüllt:
cardUser?: string; // wem die Karte gehört (SimCard.cardUser)
network?: string; // Mobilfunknetz, menschenlesbar (Telekom/Vodafone/…)
}
// Physisches Mobilfunknetz → Anzeigename.
const MOBILE_NETWORK_LABELS: Record<string, string> = {
TELEKOM: 'Telekom',
VODAFONE: 'Vodafone',
TELEFONICA: 'Telefónica (o2)',
};
export function mobileNetworkLabel(net?: string | null): string | undefined {
if (!net) return undefined;
return MOBILE_NETWORK_LABELS[net] ?? net;
} }
export function getContractTypeInfo(contract: ContractInfoData): ContractTypeInfo | null { export function getContractTypeInfo(contract: ContractInfoData): ContractTypeInfo | null {
@@ -39,9 +55,16 @@ export function getContractTypeInfo(contract: ContractInfoData): ContractTypeInf
if (!md) return null; if (!md) return null;
const mainSim = md.simCards?.find((s) => s.isMain && s.phoneNumber); const mainSim = md.simCards?.find((s) => s.isMain && s.phoneNumber);
const anySim = md.simCards?.find((s) => s.phoneNumber); const anySim = md.simCards?.find((s) => s.phoneNumber);
const phone = mainSim?.phoneNumber || anySim?.phoneNumber || md.phoneNumber; const sim = mainSim || anySim;
const phone = sim?.phoneNumber || md.phoneNumber;
if (!phone) return null; if (!phone) return null;
return { label: 'Rufnummer', value: phone }; return {
label: 'Rufnummer',
value: phone,
// Karteninhaber der angezeigten SIM (kann vom Vertragsinhaber abweichen).
cardUser: sim?.cardUser?.trim() || undefined,
network: mobileNetworkLabel(md.mobileNetwork),
};
} }
if (type === 'CAR_INSURANCE') { if (type === 'CAR_INSURANCE') {