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; + } + } +}