Merged PR 1974: feat(crm): introduce PrimaryCustomerCardResource and format-name utility

feat(crm): introduce PrimaryCustomerCardResource and format-name utility

Replace SelectedCustomerBonusCardsResource with a new PrimaryCustomerCardResource
that automatically loads and exposes the primary customer card as a signal.
This simplifies customer card access across the application by providing a
centralized, root-level injectable resource with automatic tab synchronization.

Create new @isa/utils/format-name library to consolidate customer name formatting
logic previously duplicated across components. The utility formats names with
configurable first name, last name, and organization name fields.

Key changes:
- Add PrimaryCustomerCardResource as providedIn root service with automatic
  customer selection tracking via effect
- Remove SelectedCustomerBonusCardsResource and its manual provisioning
- Extract formatName function to dedicated utility library with Vitest setup
- Update all reward-related components to use new resource pattern
- Migrate OMS components to use centralized format-name utility
- Add comprehensive unit tests for formatName function

BREAKING CHANGE: SelectedCustomerBonusCardsResource has been removed

Ref: #5389
This commit is contained in:
Nino Righi
2025-10-21 13:11:03 +00:00
committed by Lorenz Hilpert
parent f549c59bc8
commit 0b76552211
38 changed files with 592 additions and 208 deletions

View File

@@ -19,7 +19,6 @@ import {
SelectedRewardShoppingCartResource,
SelectedShoppingCartResource,
} from '@isa/checkout/data-access';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
import {
RewardSelectionService,
RewardSelectionPopUpService,
@@ -47,7 +46,6 @@ import {
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
SelectedCustomerBonusCardsResource,
RewardSelectionService,
RewardSelectionPopUpService,
],

View File

@@ -20,7 +20,6 @@ import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
@NgModule({
imports: [
@@ -41,7 +40,6 @@ import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
SelectedCustomerBonusCardsResource,
RewardSelectionService,
RewardSelectionPopUpService,
],

View File

@@ -4,10 +4,7 @@
uiButton
color="brand"
size="large"
[disabled]="
!hasSelectedItems() ||
(!!primaryBonusCard() && primaryBonusCardPoints() <= 0)
"
[disabled]="disableSelectRewardButton()"
(click)="continueToPurchasingOptions()"
>
Prämie auswählen

View File

@@ -15,10 +15,8 @@ import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { firstValueFrom } from 'rxjs';
import { Router } from '@angular/router';
import { getRouteToCustomer } from '../helpers';
import {
getPrimaryBonusCard,
SelectedCustomerBonusCardsResource,
} from '@isa/crm/data-access';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'reward-action',
@@ -32,30 +30,29 @@ export class RewardActionComponent {
#store = inject(RewardCatalogStore);
#tabId = injectTabId();
#navigationState = inject(NavigationStateService);
#purchasingOptionsModal = inject(PurchaseOptionsModalService);
#shoppingCartFacade = inject(ShoppingCartFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
.resource;
#primaryBonudCardResource = inject(PrimaryCustomerCardResource);
readonly customerCardResponseValue =
this.#customerBonusCardsResource.value.asReadonly();
bonusCards = computed(() => {
return this.customerCardResponseValue() ?? [];
});
primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards()));
primaryBonusCardPoints = computed(
() => this.primaryBonusCard()?.totalPoints ?? 0,
);
readonly primaryCustomerCardValue =
this.#primaryBonudCardResource.primaryCustomerCard;
selectedItems = computed(() => this.#store.selectedItems());
points = computed(() => this.primaryCustomerCardValue()?.totalPoints ?? 0);
hasSelectedItems = computed(() => {
return Object.keys(this.selectedItems() || {}).length > 0;
});
disableSelectRewardButton = computed(
() =>
!this.hasSelectedItems() ||
(!!this.primaryCustomerCardValue() && this.points() <= 0),
);
async continueToPurchasingOptions() {
const tabId = this.#tabId();
const items = Object.values(this.selectedItems() || {});
@@ -93,8 +90,18 @@ export class RewardActionComponent {
}
async #navigation(tabId: number) {
if (!this.primaryBonusCard()) {
// If no Customer Card -> No Customer Selected
if (!this.primaryCustomerCardValue()) {
const route = getRouteToCustomer(tabId);
// Preserve context: Store current reward page URL to return to after customer selection
await this.#navigationState.preserveContext(
{
returnUrl: this.#router.url,
},
'select-customer',
);
await this.#router.navigate(route.path, {
queryParams: route.queryParams,
});

View File

@@ -18,7 +18,7 @@ import { RewardListComponent } from './reward-list/reward-list.component';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RewardActionComponent } from './reward-action/reward-action.component';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
import { SelectedCustomerResource } from '@isa/crm/data-access';
/**
* Factory function to retrieve query settings from the activated route data.
@@ -35,7 +35,7 @@ function querySettingsFactory() {
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerBonusCardsResource,
SelectedCustomerResource,
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync(),

View File

@@ -1,9 +1,7 @@
<div class="flex flex-row gap-20 text-isa-neutral-900">
<div class="flex flex-col gap-[0.125rem]">
<div class="flex flex-col gap-1">
<span class="isa-text-body-1-regular"
>{{ primaryBonusCard()?.firstName }} {{ primaryBonusCard()?.lastName }}
</span>
<span class="isa-text-body-1-regular">{{ customerName() }} </span>
<span class="isa-text-body-1-bold"
>{{ primaryBonusCardPoints() }} Lesepunkte</span
>

View File

@@ -7,8 +7,8 @@ import {
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import {
CrmTabMetadataService,
getPrimaryBonusCard,
SelectedCustomerBonusCardsResource,
PrimaryCustomerCardResource,
SelectedCustomerResource,
} from '@isa/crm/data-access';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
import {
@@ -17,6 +17,7 @@ import {
} from '@isa/checkout/data-access';
import { RouterLink } from '@angular/router';
import { injectTabId } from '@isa/core/tabs';
import { formatName } from '@isa/utils/format-name';
@Component({
selector: 'reward-customer-card',
@@ -34,20 +35,17 @@ export class RewardCustomerCardComponent {
#crmTabMetadataService = inject(CrmTabMetadataService);
#checkoutMetadataService = inject(CheckoutMetadataService);
tabId = injectTabId();
#customerResource = inject(SelectedCustomerResource).resource;
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
.resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
readonly customerCardResponseValue =
this.#customerBonusCardsResource.value.asReadonly();
readonly customerValue = this.#customerResource.value.asReadonly();
bonusCards = computed(() => {
return this.customerCardResponseValue() ?? [];
});
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards()));
primaryBonusCardPoints = computed(
() => this.primaryBonusCard()?.totalPoints ?? 0,
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
);
readonly shoppingCartResponseValue =
@@ -58,6 +56,15 @@ export class RewardCustomerCardComponent {
() => this.shoppingCartResponseValue()?.items?.length ?? 0,
);
customerName = computed(() => {
const customer = this.customerValue();
return formatName({
firstName: customer?.firstName,
lastName: customer?.lastName,
organisationName: customer?.organisation?.name,
});
});
resetCustomerAndCart() {
this.#crmTabMetadataService.setSelectedCustomerId(this.tabId()!, undefined);
this.#checkoutMetadataService.setRewardShoppingCartId(

View File

@@ -1,5 +1,5 @@
@let card = primaryBonusCard();
@let cardFetching = customerCardResponseFetching();
@let card = primaryCustomerCardValue();
@let cardFetching = primaryCustomerCardFetching();
@if (!cardFetching) {
@if (card) {
<reward-customer-card></reward-customer-card>

View File

@@ -1,15 +1,13 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { RewardStartCardComponent } from './reward-start-card/reward-start-card.component';
import { RewardCustomerCardComponent } from './reward-customer-card/reward-customer-card.component';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
getPrimaryBonusCard,
SelectedCustomerBonusCardsResource,
PrimaryCustomerCardResource,
} from '@isa/crm/data-access';
@Component({
selector: 'reward-header',
@@ -23,17 +21,10 @@ import {
],
})
export class RewardHeaderComponent {
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
.resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
readonly customerCardResponseValue =
this.#customerBonusCardsResource.value.asReadonly();
readonly customerCardResponseFetching =
this.#customerBonusCardsResource.isLoading;
bonusCards = computed(() => {
return this.customerCardResponseValue() ?? [];
});
primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards()));
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
readonly primaryCustomerCardFetching =
this.#primaryCustomerCardResource.loading;
}

View File

@@ -4,6 +4,7 @@ import {
input,
linkedSignal,
inject,
OnDestroy,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { FormsModule } from '@angular/forms';
@@ -17,7 +18,7 @@ import { RewardCatalogStore } from '@isa/checkout/data-access';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, CheckboxComponent],
})
export class RewardListItemSelectComponent {
export class RewardListItemSelectComponent implements OnDestroy {
#store = inject(RewardCatalogStore);
item = input.required<Item>();
@@ -36,4 +37,9 @@ export class RewardListItemSelectComponent {
this.#store.removeItem(itemId);
}
}
ngOnDestroy() {
const itemId = this.item().id;
this.#store.removeItem(itemId);
}
}

View File

@@ -1,5 +1,5 @@
<div class="isa-text-body-1-bold">
<h4 class="isa-text-body-1-regular mb-1">Rechnugsadresse</h4>
<h4 class="isa-text-body-1-regular mb-1">Rechnungsadresse</h4>
<div>
@if (payer(); as payer) {
<div>{{ payerName() }}</div>
@@ -30,6 +30,5 @@
size="large"
color="secondary"
type="button"
>
</button>
></button>
</div>

View File

@@ -1,6 +1,6 @@
<div class="info-block">
<div class="info-block--label">{{ customerName() }}</div>
<div class="info-block--value">{{ bonusCardPoints() }} Lesepunkte</div>
<div class="info-block--value">{{ primaryBonusCardPoints() }} Lesepunkte</div>
</div>
<div class="info-block">
<div class="info-block--label">Prämien ausgewählt</div>
@@ -13,6 +13,6 @@
<div class="info-block">
<div class="info-block--label">Verbleibende Lesepunkte</div>
<div class="info-block--value">
{{ bonusCardPoints() - totalPointsRequired() }} Lesepunkte
{{ primaryBonusCardPoints() - totalPointsRequired() }} Lesepunkte
</div>
</div>

View File

@@ -7,8 +7,7 @@ import {
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
SelectedCustomerBonusCardsResource,
getPrimaryBonusCard,
PrimaryCustomerCardResource,
} from '@isa/crm/data-access';
@Component({
@@ -21,8 +20,7 @@ import {
export class CheckoutCustomerRewardCardComponent {
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#customerResource = inject(SelectedCustomerResource).resource;
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
.resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
customerLoading = this.#customerResource.isLoading;
@@ -51,14 +49,12 @@ export class CheckoutCustomerRewardCardComponent {
return loyalty.reduce((a, b) => a + b, 0);
});
bonusCardsLoading = this.#customerBonusCardsResource.isLoading;
bonusCardsLoading = this.#primaryCustomerCardResource.loading;
bonusCards = computed(() => {
return this.#customerBonusCardsResource.value() ?? [];
});
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
bonusCardPoints = computed(() => {
const primary = getPrimaryBonusCard(this.bonusCards());
return primary?.totalPoints ?? 0;
});
primaryBonusCardPoints = computed(
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
);
}

View File

@@ -6,7 +6,6 @@ import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/r
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
SelectedCustomerBonusCardsResource,
} from '@isa/crm/data-access';
import { CompleteOrderButtonComponent } from './complete-order-button/complete-order-button.component';
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
@@ -27,7 +26,6 @@ import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-sel
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerResource,
SelectedCustomerBonusCardsResource,
],
})
export class RewardShoppingCartComponent {}

View File

@@ -53,7 +53,6 @@ import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
@Component({
selector: 'app-custom-checkout',
@@ -62,7 +61,6 @@ import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
// Required providers
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
SelectedCustomerBonusCardsResource,
RewardSelectionService,
RewardSelectionPopUpService,
],
@@ -92,7 +90,6 @@ import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
@Component({
selector: 'app-advanced',
@@ -106,7 +103,6 @@ import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
SelectedCustomerBonusCardsResource,
RewardSelectionService,
],
})
@@ -180,7 +176,6 @@ When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, p
providers: [
SelectedShoppingCartResource, // Regular cart data
SelectedRewardShoppingCartResource, // Reward cart data
SelectedCustomerBonusCardsResource, // Customer bonus cards
RewardSelectionService, // Core service
RewardSelectionPopUpService, // Optional: only if using pop-up
]

View File

@@ -10,10 +10,7 @@ import {
RewardSelectionDialogComponent,
RewardSelectionDialogResult,
} from '../reward-selection-dialog.component';
import {
getPrimaryBonusCard,
SelectedCustomerBonusCardsResource,
} from '@isa/crm/data-access';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
import { firstValueFrom } from 'rxjs';
import { RewardSelectionItem } from '@isa/checkout/data-access';
import { toObservable } from '@angular/core/rxjs-interop';
@@ -27,11 +24,10 @@ import {
@Injectable()
export class RewardSelectionService {
priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
.resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
#priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
#shoppingCartResource = inject(SelectedShoppingCartResource).resource;
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
.resource;
@@ -42,11 +38,11 @@ export class RewardSelectionService {
readonly rewardShoppingCartResponseValue =
this.#rewardShoppingCartResource.value.asReadonly();
readonly customerCardResponseValue =
this.#customerBonusCardsResource.value.asReadonly();
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
readonly priceAndRedemptionPoints =
this.priceAndRedemptionPointsResource.priceAndRedemptionPoints;
this.#priceAndRedemptionPointsResource.priceAndRedemptionPoints;
shoppingCartItems = computed(() => {
return (
@@ -104,21 +100,16 @@ export class RewardSelectionService {
});
});
bonusCards = computed(() => {
return this.customerCardResponseValue() ?? [];
});
primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards()));
primaryBonusCardPoints = computed(
() => this.primaryBonusCard()?.totalPoints ?? 0,
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
);
isLoading = computed(
() =>
this.#shoppingCartResource.isLoading() ||
this.#rewardShoppingCartResource.isLoading() ||
this.#customerBonusCardsResource.isLoading() ||
this.priceAndRedemptionPointsResource.loading(),
this.#primaryCustomerCardResource.loading() ||
this.#priceAndRedemptionPointsResource.loading(),
);
#isLoading$ = toObservable(this.isLoading);
@@ -133,7 +124,7 @@ export class RewardSelectionService {
);
canOpen = computed(
() => this.eligibleItems().length > 0 && !!this.primaryBonusCard(),
() => this.eligibleItems().length > 0 && !!this.primaryBonusCardPoints(),
);
constructor() {
@@ -141,9 +132,11 @@ export class RewardSelectionService {
const items = this.selectionItemsWithOrderType();
untracked(() => {
const resourceLoading = this.priceAndRedemptionPointsResource.loading();
const resourceLoading =
this.#priceAndRedemptionPointsResource.loading();
if (!resourceLoading && items.length > 0) {
this.priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
this.#priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
items,
);
}
@@ -157,7 +150,6 @@ export class RewardSelectionService {
closeText: string;
}): Promise<RewardSelectionDialogResult> {
const rewardSelectionItems = this.eligibleItems();
console.log(rewardSelectionItems);
const dialogRef = this.rewardSelectionDialog({
title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
data: {
@@ -185,12 +177,11 @@ export class RewardSelectionService {
async reloadResources(): Promise<void> {
// Start reloading all resources
// Note: Price and redemption points will be loaded automatically by the effect
// Note: PrimaryCustomerCard, Price and redemption points will be loaded automatically by the effect
// when selectionItemsWithOrderType changes after cart resources are reloaded
await Promise.all([
this.#shoppingCartResource.reload(),
this.#rewardShoppingCartResource.reload(),
this.#customerBonusCardsResource.reload(),
]);
// Wait until all resources are fully loaded (isLoading becomes false)

View File

@@ -6,7 +6,6 @@ import { DomainCheckoutService } from '@domain/checkout';
import { injectFeedbackDialog } from '@isa/ui/dialog';
import { RewardSelectionService } from '../service/reward-selection.service';
import { Router } from '@angular/router';
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
import {
SelectedRewardShoppingCartResource,
SelectedShoppingCartResource,
@@ -21,7 +20,6 @@ import { CheckoutNavigationService } from '@shared/services/navigation';
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
SelectedCustomerBonusCardsResource,
RewardSelectionService,
],
})

View File

@@ -1,49 +0,0 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmSearchService, CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
@Injectable()
export class CustomerBonusCardsResource {
#customerService = inject(CrmSearchService);
#params = signal<{ customerId: number | undefined }>({
customerId: undefined,
});
params(params: { customerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }) => {
if (!params.customerId) {
return undefined;
}
const res = await this.#customerService.fetchCustomerCards(
{
customerId: params.customerId,
},
abortSignal,
);
return res.result;
},
});
}
@Injectable()
export class SelectedCustomerBonusCardsResource extends CustomerBonusCardsResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const customerId = tabId
? this.#customerMetadata.selectedCustomerId(tabId)
: undefined;
this.params({ customerId });
});
}
}

View File

@@ -1,5 +1,5 @@
export * from './country.resource';
export * from './customer-bonus-cards.resource';
export * from './primary-customer-card.resource';
export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';

View File

@@ -0,0 +1,121 @@
import {
computed,
effect,
inject,
Injectable,
resource,
signal,
untracked,
} from '@angular/core';
import { CrmSearchService, CrmTabMetadataService } from '../services';
import { injectTabId } from '@isa/core/tabs';
import { BonusCardInfo } from '../models';
import { getPrimaryBonusCard } from '../helpers';
/**
* Resource for the primary customer card of a customer.
*
* This resource automatically loads the primary bonus card of a customer
* as soon as a `customerId` is set in the tab metadata. An internal effect
* monitors changes to the `selectedCustomerId` in the active tab and
* triggers loading of the customer card automatically.
*
* @example
* ```typescript
* const resource = inject(PrimaryCustomerCardResource);
*
* // Primary customer card (automatically loaded via effect)
* const card = resource.primaryCustomerCard();
*
* // Loading status
* const isLoading = resource.loading();
* ```
*/
@Injectable({ providedIn: 'root' })
export class PrimaryCustomerCardResource {
#customerMetadata = inject(CrmTabMetadataService);
#customerService = inject(CrmSearchService);
#tabId = injectTabId();
#customerId = signal<number | undefined>(undefined);
#primaryCustomerCardResource = resource({
params: computed(() => ({ customerId: this.#customerId() })),
loader: async ({
params,
abortSignal,
}): Promise<BonusCardInfo | undefined> => {
const tabId = this.#tabId();
if (!tabId || !params.customerId) {
return undefined;
}
const res = await this.#customerService.fetchCustomerCards(
{ customerId: params.customerId },
abortSignal,
);
if (res?.result?.length > 0) {
return getPrimaryBonusCard(res.result);
}
return undefined;
},
defaultValue: undefined,
});
/**
* Signal containing the primary customer card of the selected customer.
* Automatically updated when the `selectedCustomerId` changes in the tab metadata.
*/
readonly primaryCustomerCard =
this.#primaryCustomerCardResource.value.asReadonly();
/**
* Signal indicating whether the customer card is currently being loaded.
*/
readonly loading = this.#primaryCustomerCardResource.isLoading;
/**
* Signal containing an error message if an error occurred during loading.
* Returns `null` if no error is present.
*/
readonly error = computed(
() => this.#primaryCustomerCardResource.error()?.message ?? null,
);
/**
* Loads the primary customer card for the specified customer ID.
*
* @param customerId - The ID of the customer whose primary card should be loaded
*/
loadPrimaryCustomerCard(customerId: number | undefined) {
this.#customerId.set(customerId);
}
constructor() {
/**
* Effect: Monitors changes to the `selectedCustomerId` in the tab metadata
* and automatically loads the primary customer card whenever a new customer ID
* is set. This ensures that the customer card is always in sync with the
* customer selection in the active tab.
*
* Uses `untracked` to prevent creating additional signal dependencies when
* checking if the customer ID has actually changed, avoiding unnecessary reloads.
*/
effect(() => {
const tabId = this.#tabId();
let customerId = undefined;
if (tabId) {
customerId = this.#customerMetadata.selectedCustomerId(tabId);
}
untracked(() => {
if (this.#customerId() !== customerId) {
this.loadPrimaryCustomerCard(customerId);
}
});
});
}
}

View File

@@ -8,7 +8,7 @@ import {
import { isaActionChevronDown, isaNavigationKunden } from '@isa/icons';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { formatName } from 'libs/oms/utils/format-name';
import { formatName } from '@isa/utils/format-name';
import { UiMenu } from '@isa/ui/menu';
import { RouterLink } from '@angular/router';
import { CdkMenuTrigger } from '@angular/cdk/menu';

View File

@@ -1,9 +1,9 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnSearchResultItemComponent } from './return-search-result-item.component';
import { ReceiptListItem } from '@isa/oms/data-access';
import { formatName } from 'libs/oms/utils/format-name';
import { formatName } from '@isa/utils/format-name';
jest.mock('libs/oms/utils/format-name', () => ({
jest.mock('@isa/utils/format-name', () => ({
formatName: jest.fn(() => 'Formatted Name'),
}));

View File

@@ -7,7 +7,7 @@ import {
} from '@angular/core';
import { ReceiptListItem } from '@isa/oms/data-access';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { formatName } from 'libs/oms/utils/format-name';
import { formatName } from '@isa/utils/format-name';
@Component({
selector: 'oms-feature-return-search-result-item',

View File

@@ -1,17 +0,0 @@
// TODO: Create proper library or add to existing one - or we will run into an import issue!!
export function formatName({
firstName,
lastName,
organisationName,
}: {
firstName?: string;
lastName?: string;
organisationName?: string;
}) {
const nameCombined = [lastName, firstName].filter((f) => !!f);
const organisation = [organisationName].filter((f) => !!f);
return [organisation.join(), nameCombined.join(' ')]
.filter((f) => !!f)
.join(' - ');
}

View File

@@ -1 +0,0 @@
export * from './format-name.function';

View File

@@ -0,0 +1,7 @@
# format-name
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test format-name` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "format-name",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/utils/format-name/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/utils/format-name"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/format-name/format-name.component';

View File

@@ -0,0 +1,125 @@
import { formatName } from './format-name.component';
describe('formatName', () => {
it('should format full name with first and last name', () => {
// Arrange
const input = {
firstName: 'John',
lastName: 'Doe',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Doe John');
});
it('should format name with organisation and full name', () => {
// Arrange
const input = {
firstName: 'John',
lastName: 'Doe',
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp - Doe John');
});
it('should format with only organisation name', () => {
// Arrange
const input = {
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp');
});
it('should format with only last name', () => {
// Arrange
const input = {
lastName: 'Doe',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Doe');
});
it('should format with only first name', () => {
// Arrange
const input = {
firstName: 'John',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('John');
});
it('should return empty string when all inputs are undefined', () => {
// Arrange
const input = {};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('');
});
it('should return empty string when all inputs are empty strings', () => {
// Arrange
const input = {
firstName: '',
lastName: '',
organisationName: '',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('');
});
it('should format with organisation and only last name', () => {
// Arrange
const input = {
lastName: 'Doe',
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp - Doe');
});
it('should format with organisation and only first name', () => {
// Arrange
const input = {
firstName: 'John',
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp - John');
});
});

View File

@@ -0,0 +1,45 @@
/**
* Formats a name by combining first name, last name, and organisation name.
*
* The function follows these formatting rules:
* - Names are formatted as "LastName FirstName"
* - Organisation name is separated from the personal name with " - "
* - Empty or undefined values are filtered out
* - If all values are empty/undefined, returns an empty string
*
* @param params - The name components to format
* @param params.firstName - The person's first name (optional)
* @param params.lastName - The person's last name (optional)
* @param params.organisationName - The organisation name (optional)
* @returns The formatted name string
*
* @example
* formatName({ firstName: 'John', lastName: 'Doe' })
* // Returns: "Doe John"
*
* @example
* formatName({ firstName: 'John', lastName: 'Doe', organisationName: 'Acme Corp' })
* // Returns: "Acme Corp - Doe John"
*
* @example
* formatName({ organisationName: 'Acme Corp' })
* // Returns: "Acme Corp"
*/
export const formatName = ({
firstName,
lastName,
organisationName,
}: {
firstName?: string;
lastName?: string;
organisationName?: string;
}): string => {
const nameCombined = [lastName, firstName].filter((f) => !!f);
const organisation = [organisationName].filter((f) => !!f);
return (
[organisation.join(), nameCombined.join(' ')]
.filter((f) => !!f)
.join(' - ') ?? ''
);
};

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,27 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/utils/format-name',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/utils/format-name',
provider: 'v8' as const,
},
},
}));

44
package-lock.json generated
View File

@@ -1221,6 +1221,7 @@
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.1.2.tgz",
"integrity": "sha512-NMSDavN+CJYvSze6wq7DpbrUA/EqiAD7GQoeJtuOknzUpPlWQmFOoHzTMKW+S34XlNEw+YQT0trv3DKcrE+T/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.28.0",
@@ -1253,6 +1254,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
@@ -1283,12 +1285,14 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1298,6 +1302,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
@@ -1577,6 +1582,7 @@
"version": "7.27.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz",
"integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
@@ -1607,12 +1613,14 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1622,6 +1630,7 @@
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.5",
@@ -12988,17 +12997,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -15662,6 +15660,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -16190,6 +16189,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -18198,7 +18198,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.2"
@@ -18208,7 +18208,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -31835,17 +31835,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -31885,6 +31874,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -31939,6 +31929,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/regenerate": {
@@ -32714,7 +32705,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/sass": {
@@ -33332,6 +33323,7 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -35870,7 +35862,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@@ -138,6 +138,7 @@
"@isa/ui/toolbar": ["libs/ui/toolbar/src/index.ts"],
"@isa/ui/tooltip": ["libs/ui/tooltip/src/index.ts"],
"@isa/utils/ean-validation": ["libs/utils/ean-validation/src/index.ts"],
"@isa/utils/format-name": ["libs/utils/format-name/src/index.ts"],
"@isa/utils/scroll-position": ["libs/utils/scroll-position/src/index.ts"],
"@isa/utils/z-safe-parse": ["libs/utils/z-safe-parse/src/index.ts"],
"@modal/*": ["apps/isa-app/src/modal/*/index.ts"],