This commit is contained in:
Nino
2025-11-10 16:16:21 +01:00
10 changed files with 309 additions and 20 deletions

View File

@@ -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,

View File

@@ -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;
});
}

View File

@@ -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<CrmPayer | undefined> => {
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 });
});
}
}

View File

@@ -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';

View File

@@ -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<PayerDTO | undefined> => {
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 });
});
}
}

View File

@@ -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<typeof FetchPayerSchema>;
export type FetchPayerInput = z.input<typeof FetchPayerSchema>;

View File

@@ -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';

View File

@@ -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<typeof PayerSchema>;

View File

@@ -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';

View File

@@ -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<ResponseArgs<CrmPayer>> {
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<CrmPayer>;
} catch (error) {
this.#logger.error('Error fetching payer', error);
return undefined as unknown as ResponseArgs<CrmPayer>;
}
}
}