From c3e9a03169a509102acefda2e68eee5f06961244 Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Mon, 10 Nov 2025 15:10:56 +0000 Subject: [PATCH] Merged PR 2015: fix(crm-data-access, customer-details, reward-shopping-cart): persist selecte... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(crm-data-access, customer-details, reward-shopping-cart): persist selected addresses across navigation flows Implement address selection persistence using CRM tab metadata to ensure selected shipping addresses and payers are retained throughout the customer selection flow, particularly when navigating from Kundenkarte to reward cart. Changes include: - Create PayerResource and CustomerPayerAddressResource to load selected payer from tab metadata with fallback to customer as payer - Create PayerService to fetch payer data from CRM API with proper error handling and abort signal support - Update BillingAndShippingAddressCardComponent to prefer selected addresses from metadata over customer defaults, with computed loading state - Refactor continue() flow in CustomerDetailsViewMainComponent to load selected addresses from metadata before setting in checkout service - Add adapter logic to convert CRM payer/shipping address types to checkout types with proper type casting for incompatible enum types - Implement fallback chain: metadata selection → component state → customer default for both payer and shipping address This ensures address selections made in the address selection dialogs are properly preserved and applied when completing the customer selection flow, fixing the issue where addresses would revert to customer defaults. Ref: #5411 --- .../details-main-view.component.ts | 95 +++++++++++++++++-- ...ing-and-shipping-address-card.component.ts | 39 +++++++- .../customer-payer-address.resource.ts | 54 +++++++++++ .../data-access/src/lib/resources/index.ts | 2 + .../src/lib/resources/payer.resource.ts | 52 ++++++++++ .../src/lib/schemas/fetch-payer.schema.ts | 8 ++ libs/crm/data-access/src/lib/schemas/index.ts | 1 + .../src/lib/schemas/payer.schema.ts | 34 +++++-- .../crm/data-access/src/lib/services/index.ts | 1 + .../src/lib/services/payer.service.ts | 43 +++++++++ 10 files changed, 309 insertions(+), 20 deletions(-) create mode 100644 libs/crm/data-access/src/lib/resources/customer-payer-address.resource.ts create mode 100644 libs/crm/data-access/src/lib/resources/payer.resource.ts create mode 100644 libs/crm/data-access/src/lib/schemas/fetch-payer.schema.ts create mode 100644 libs/crm/data-access/src/lib/services/payer.service.ts diff --git a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts index cdb1d5871..2a24d8f89 100644 --- a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts +++ b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts @@ -36,13 +36,21 @@ import { CrmCustomerService } from '@domain/crm'; import { MessageModalComponent, MessageModalData } from '@modal/message'; import { GenderSettingsService } from '@shared/services/gender'; import { toSignal } from '@angular/core/rxjs-interop'; -import { CrmTabMetadataService, Customer } from '@isa/crm/data-access'; -import { CustomerAdapter } from '@isa/checkout/data-access'; +import { + CrmTabMetadataService, + Customer, + AssignedPayer, +} from '@isa/crm/data-access'; +import { + CustomerAdapter, + ShippingAddressAdapter, +} from '@isa/checkout/data-access'; import { NavigateAfterRewardSelection, RewardSelectionPopUpService, } from '@isa/checkout/shared/reward-selection-dialog'; import { NavigationStateService } from '@isa/core/navigation'; +import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api'; export interface CustomerDetailsViewMainState { isBusy: boolean; @@ -407,9 +415,9 @@ export class CustomerDetailsViewMainComponent await this._updateNotifcationChannelsAsync(currentBuyer); - this._setPayer(); + await this._setPayer(); - this._setShippingAddress(); + await this._setShippingAddress(); // #5262 Check for reward selection flow before navigation if (this.hasReturnUrl()) { @@ -631,8 +639,46 @@ export class CustomerDetailsViewMainComponent } } - @log - _setPayer() { + @logAsync + async _setPayer() { + // Check if there's a selected payer in metadata (from previous address selection) + const selectedPayerId = this.crmTabMetadataService.selectedPayerId( + this.processId, + ); + + if (selectedPayerId) { + // Load the selected payer from metadata + try { + const payerResponse = await this.customerService + .getPayer(selectedPayerId) + .toPromise(); + + if (payerResponse?.result) { + // Create AssignedPayer structure expected by adapter + // Type cast needed due to incompatible enum types between CRM and Checkout APIs + const assignedPayer = { + payer: { + id: selectedPayerId, + data: payerResponse.result, + }, + } as AssignedPayer; + + const payer = CustomerAdapter.toPayerFromAssignedPayer(assignedPayer); + + if (payer) { + this._checkoutService.setPayer({ + processId: this.processId, + payer, + }); + return; + } + } + } catch (error) { + console.error('Failed to load selected payer from metadata', error); + } + } + + // Fallback to current payer from component state if (this.payer) { this._checkoutService.setPayer({ processId: this.processId, @@ -641,8 +687,41 @@ export class CustomerDetailsViewMainComponent } } - @log - _setShippingAddress() { + @logAsync + async _setShippingAddress() { + // Check if there's a selected shipping address in metadata (from previous address selection) + const selectedShippingAddressId = + this.crmTabMetadataService.selectedShippingAddressId(this.processId); + + if (selectedShippingAddressId) { + // Load the selected shipping address from metadata + try { + const addressResponse = await this.customerService + .getShippingAddress(selectedShippingAddressId) + .toPromise(); + + if (addressResponse?.result) { + const shippingAddress = ShippingAddressAdapter.fromCrmShippingAddress( + addressResponse.result as CrmShippingAddressDTO, + ); + + if (shippingAddress) { + this._checkoutService.setShippingAddress({ + processId: this.processId, + shippingAddress, + }); + return; + } + } + } catch (error) { + console.error( + 'Failed to load selected shipping address from metadata', + error, + ); + } + } + + // Fallback to current shipping address from component state if (this.shippingAddress) { this._checkoutService.setShippingAddress({ processId: this.processId, diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts index 4844b9b05..ddac3ac70 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts @@ -7,6 +7,8 @@ import { import { SelectedCustomerResource, getCustomerName, + SelectedCustomerShippingAddressResource, + SelectedCustomerPayerAddressResource, } from '@isa/crm/data-access'; import { isaActionEdit } from '@isa/icons'; import { IconButtonComponent } from '@isa/ui/buttons'; @@ -25,10 +27,19 @@ import { NavigationStateService } from '@isa/core/navigation'; }) export class BillingAndShippingAddressCardComponent { #navigationState = inject(NavigationStateService); + #shippingAddressResource = inject(SelectedCustomerShippingAddressResource); + #payerAddressResource = inject(SelectedCustomerPayerAddressResource); + tabId = injectTabId(); #customerResource = inject(SelectedCustomerResource).resource; - isLoading = this.#customerResource.isLoading; + isLoading = computed(() => { + return ( + this.#customerResource.isLoading() || + this.#shippingAddressResource.resource.isLoading() || + this.#payerAddressResource.resource.isLoading() + ); + }); customer = computed(() => { return this.#customerResource.value(); @@ -49,26 +60,44 @@ export class BillingAndShippingAddressCardComponent { } payer = computed(() => { + // Prefer selected payer from metadata over customer as payer + const selectedPayer = this.#payerAddressResource.resource.value(); + if (selectedPayer) { + return selectedPayer; + } + // Fallback to customer as payer return this.customer(); }); payerName = computed(() => { - return getCustomerName(this.payer()); + const payer = this.payer(); + return getCustomerName(payer); }); payerAddress = computed(() => { - return this.customer()?.address; + const payer = this.payer(); + if (!payer) return undefined; + return payer.address; }); shippingAddress = computed(() => { + // Prefer selected shipping address from metadata over customer default + const selectedAddress = this.#shippingAddressResource.resource.value(); + if (selectedAddress) { + return selectedAddress; + } + // Fallback to customer return this.customer(); }); shippingName = computed(() => { - return getCustomerName(this.shippingAddress()); + const shipping = this.shippingAddress(); + return getCustomerName(shipping); }); shippingAddressAddress = computed(() => { - return this.shippingAddress()?.address; + const shipping = this.shippingAddress(); + if (!shipping) return undefined; + return shipping.address; }); } diff --git a/libs/crm/data-access/src/lib/resources/customer-payer-address.resource.ts b/libs/crm/data-access/src/lib/resources/customer-payer-address.resource.ts new file mode 100644 index 000000000..203488531 --- /dev/null +++ b/libs/crm/data-access/src/lib/resources/customer-payer-address.resource.ts @@ -0,0 +1,54 @@ +import { effect, inject, Injectable, resource, signal } from '@angular/core'; +import { CrmTabMetadataService, PayerService } from '../services'; +import { TabService } from '@isa/core/tabs'; +import { CrmPayer } from '../schemas'; + +@Injectable() +export class CustomerPayerAddressResource { + #payerService = inject(PayerService); + + #params = signal<{ + payerId: number | undefined; + }>({ + payerId: undefined, + }); + + params(params: { payerId?: number }) { + this.#params.update((p) => ({ ...p, ...params })); + } + + readonly resource = resource({ + params: () => this.#params(), + loader: async ({ params, abortSignal }): Promise => { + if (!params.payerId) { + return undefined; + } + + const res = await this.#payerService.fetchPayer( + { + payerId: params.payerId, + }, + abortSignal, + ); + + return res.result as CrmPayer; + }, + }); +} + +@Injectable({ providedIn: 'root' }) +export class SelectedCustomerPayerAddressResource extends CustomerPayerAddressResource { + #tabId = inject(TabService).activatedTabId; + #customerMetadata = inject(CrmTabMetadataService); + + constructor() { + super(); + effect(() => { + const tabId = this.#tabId(); + const payerId = tabId + ? this.#customerMetadata.selectedPayerId(tabId) + : undefined; + this.params({ payerId }); + }); + } +} diff --git a/libs/crm/data-access/src/lib/resources/index.ts b/libs/crm/data-access/src/lib/resources/index.ts index a310d3353..14c0fe36b 100644 --- a/libs/crm/data-access/src/lib/resources/index.ts +++ b/libs/crm/data-access/src/lib/resources/index.ts @@ -1,5 +1,7 @@ export * from './country.resource'; +export * from './customer-payer-address.resource'; export * from './primary-customer-card.resource'; export * from './customer-shipping-address.resource'; export * from './customer-shipping-addresses.resource'; export * from './customer.resource'; +export * from './payer.resource'; diff --git a/libs/crm/data-access/src/lib/resources/payer.resource.ts b/libs/crm/data-access/src/lib/resources/payer.resource.ts new file mode 100644 index 000000000..d2a1de699 --- /dev/null +++ b/libs/crm/data-access/src/lib/resources/payer.resource.ts @@ -0,0 +1,52 @@ +import { effect, inject, Injectable, resource, signal } from '@angular/core'; +import { CrmTabMetadataService } from '../services'; +import { TabService } from '@isa/core/tabs'; +import { CrmCustomerService } from '@domain/crm'; +import { PayerDTO } from '@generated/swagger/crm-api'; + +@Injectable() +export class PayerResource { + #customerService = inject(CrmCustomerService); + + #params = signal<{ + payerId: number | undefined; + }>({ + payerId: undefined, + }); + + params(params: { payerId?: number }) { + this.#params.update((p) => ({ ...p, ...params })); + } + + readonly resource = resource({ + params: () => this.#params(), + loader: async ({ params, abortSignal }): Promise => { + if (!params.payerId) { + return undefined; + } + + const res = await this.#customerService + .getPayer(params.payerId) + .toPromise(); + + return res?.result; + }, + }); +} + +@Injectable({ providedIn: 'root' }) +export class SelectedPayerResource extends PayerResource { + #tabId = inject(TabService).activatedTabId; + #customerMetadata = inject(CrmTabMetadataService); + + constructor() { + super(); + effect(() => { + const tabId = this.#tabId(); + const payerId = tabId + ? this.#customerMetadata.selectedPayerId(tabId) + : undefined; + this.params({ payerId }); + }); + } +} diff --git a/libs/crm/data-access/src/lib/schemas/fetch-payer.schema.ts b/libs/crm/data-access/src/lib/schemas/fetch-payer.schema.ts new file mode 100644 index 000000000..c6f976789 --- /dev/null +++ b/libs/crm/data-access/src/lib/schemas/fetch-payer.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const FetchPayerSchema = z.object({ + payerId: z.number().int().describe('Payer identifier'), +}); + +export type FetchPayer = z.infer; +export type FetchPayerInput = z.input; diff --git a/libs/crm/data-access/src/lib/schemas/index.ts b/libs/crm/data-access/src/lib/schemas/index.ts index 1e53837e8..e8e37ffbd 100644 --- a/libs/crm/data-access/src/lib/schemas/index.ts +++ b/libs/crm/data-access/src/lib/schemas/index.ts @@ -8,6 +8,7 @@ export * from './customer-feature-groups.schema'; export * from './fetch-customer-cards.schema'; export * from './fetch-customer-shipping-addresses.schema'; export * from './fetch-customer.schema'; +export * from './fetch-payer.schema'; export * from './fetch-shipping-address.schema'; export * from './linked-record.schema'; export * from './notification-channel.schema'; diff --git a/libs/crm/data-access/src/lib/schemas/payer.schema.ts b/libs/crm/data-access/src/lib/schemas/payer.schema.ts index 86f9d1012..8cdacc55d 100644 --- a/libs/crm/data-access/src/lib/schemas/payer.schema.ts +++ b/libs/crm/data-access/src/lib/schemas/payer.schema.ts @@ -16,23 +16,43 @@ export const PayerSchema = z .object({ address: AddressSchema.describe('Address').optional(), agentComment: z.string().describe('Agent comment').optional(), - communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(), + communicationDetails: CommunicationDetailsSchema.describe( + 'Communication details', + ).optional(), deactivationComment: z.string().describe('Deactivation comment').optional(), - defaultPaymentPeriod: z.number().describe('Default payment period').optional(), + defaultPaymentPeriod: z + .number() + .describe('Default payment period') + .optional(), firstName: z.string().describe('First name').optional(), gender: GenderSchema.describe('Gender').optional(), isGuestAccount: z.boolean().describe('Whether guestAccount').optional(), label: EntityContainerSchema(LabelSchema).describe('Label').optional(), lastName: z.string().describe('Last name').optional(), - organisation: OrganisationSchema.describe('Organisation information').optional(), + organisation: OrganisationSchema.describe( + 'Organisation information', + ).optional(), payerGroup: z.string().describe('Payer group').optional(), payerNumber: z.string().describe('Unique payer account number').optional(), - payerStatus: PayerStatusSchema.describe('Current status of the payer account').optional(), + payerStatus: PayerStatusSchema.describe( + 'Current status of the payer account', + ).optional(), payerType: z.nativeEnum(PayerType).describe('Payer type').optional(), - paymentTypes: z.array(PaymentSettingsSchema).describe('Payment types').optional(), - standardInvoiceText: z.string().describe('Standard invoice text').optional(), - statusChangeComment: z.string().describe('Status change comment').optional(), + paymentTypes: z + .array(PaymentSettingsSchema) + .describe('Payment types') + .optional(), + standardInvoiceText: z + .string() + .describe('Standard invoice text') + .optional(), + statusChangeComment: z + .string() + .describe('Status change comment') + .optional(), statusComment: z.string().describe('Status comment').optional(), title: z.string().describe('Title').optional(), }) .extend(EntitySchema.shape); + +export type CrmPayer = z.infer; diff --git a/libs/crm/data-access/src/lib/services/index.ts b/libs/crm/data-access/src/lib/services/index.ts index bd55c18f9..7a093c079 100644 --- a/libs/crm/data-access/src/lib/services/index.ts +++ b/libs/crm/data-access/src/lib/services/index.ts @@ -1,4 +1,5 @@ export * from './country.service'; export * from './crm-search.service'; export * from './crm-tab-metadata.service'; +export * from './payer.service'; export * from './shipping-address.service'; diff --git a/libs/crm/data-access/src/lib/services/payer.service.ts b/libs/crm/data-access/src/lib/services/payer.service.ts new file mode 100644 index 000000000..cbd5efd57 --- /dev/null +++ b/libs/crm/data-access/src/lib/services/payer.service.ts @@ -0,0 +1,43 @@ +import { inject, Injectable } from '@angular/core'; +import { PayerService as GeneratedPayerService } from '@generated/swagger/crm-api'; +import { + catchResponseArgsErrorPipe, + ResponseArgs, + takeUntilAborted, +} from '@isa/common/data-access'; +import { firstValueFrom } from 'rxjs'; +import { logger } from '@isa/core/logging'; +import { FetchPayerInput, FetchPayerSchema, CrmPayer } from '../schemas'; + +@Injectable({ providedIn: 'root' }) +export class PayerService { + #payerService = inject(GeneratedPayerService); + #logger = logger(() => ({ + service: 'PayerService', + })); + + async fetchPayer( + params: FetchPayerInput, + abortSignal?: AbortSignal, + ): Promise> { + this.#logger.info('Fetching payer from API'); + const { payerId } = FetchPayerSchema.parse(params); + + let req$ = this.#payerService + .PayerGetPayer(payerId) + .pipe(catchResponseArgsErrorPipe()); + + if (abortSignal) { + req$ = req$.pipe(takeUntilAborted(abortSignal)); + } + + try { + const res = await firstValueFrom(req$); + this.#logger.debug('Successfully fetched payer'); + return res as ResponseArgs; + } catch (error) { + this.#logger.error('Error fetching payer', error); + return undefined as unknown as ResponseArgs; + } + } +}