mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Compare commits
15 Commits
fix/5411-R
...
4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c643d988fa | ||
|
|
463e46e17a | ||
|
|
c98d5666a4 | ||
|
|
835546a799 | ||
|
|
f261fc9987 | ||
|
|
cc186dbbe2 | ||
|
|
6df02d9e86 | ||
|
|
4a7b74a6c5 | ||
|
|
9c989055cb | ||
|
|
2e0853c91a | ||
|
|
c5ea5ed3ec | ||
|
|
7c29429040 | ||
|
|
c3e9a03169 | ||
|
|
3c13a230cc | ||
|
|
0a5b1dac71 |
@@ -68,7 +68,7 @@ import {
|
||||
matWifiOff,
|
||||
} from '@ng-icons/material-icons/baseline';
|
||||
import { NetworkStatusService } from './services/network-status.service';
|
||||
import { debounceTime, firstValueFrom } from 'rxjs';
|
||||
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
|
||||
import { provideMatomo } from 'ngx-matomo-client';
|
||||
import { withRouter, withRouteData } from 'ngx-matomo-client';
|
||||
import {
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
LogLevel,
|
||||
withSink,
|
||||
ConsoleLogSink,
|
||||
logger,
|
||||
logger as loggerFactory,
|
||||
} from '@isa/core/logging';
|
||||
import {
|
||||
IDBStorageProvider,
|
||||
@@ -85,57 +85,74 @@ import {
|
||||
UserStorageProvider,
|
||||
} from '@isa/core/storage';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TabNavigationService } from '@isa/core/tabs';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import z from 'zod';
|
||||
import { TabNavigationService } from '@isa/core/tabs';
|
||||
|
||||
registerLocaleData(localeDe, localeDeExtra);
|
||||
registerLocaleData(localeDe, 'de', localeDeExtra);
|
||||
|
||||
export function _appInitializerFactory(config: Config, injector: Injector) {
|
||||
return async () => {
|
||||
// Get logging service for initialization logging
|
||||
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
|
||||
const statusElement = document.querySelector('#init-status');
|
||||
const laoderElement = document.querySelector('#init-loader');
|
||||
|
||||
try {
|
||||
logger.info('Starting application initialization');
|
||||
|
||||
let online = false;
|
||||
const networkStatus = injector.get(NetworkStatusService);
|
||||
while (!online) {
|
||||
online = await firstValueFrom(networkStatus.online$);
|
||||
|
||||
if (!online) {
|
||||
logger.warn('Waiting for network connection');
|
||||
statusElement.innerHTML =
|
||||
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Network connection established');
|
||||
|
||||
statusElement.innerHTML = 'Konfigurationen werden geladen...';
|
||||
logger.info('Loading configurations');
|
||||
|
||||
statusElement.innerHTML = 'Scanner wird initialisiert...';
|
||||
logger.info('Initializing scanner');
|
||||
const scanAdapter = injector.get(ScanAdapterService);
|
||||
await scanAdapter.init();
|
||||
logger.info('Scanner initialized');
|
||||
|
||||
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
|
||||
logger.info('Initializing authentication');
|
||||
|
||||
const auth = injector.get(AuthService);
|
||||
try {
|
||||
await auth.init();
|
||||
} catch {
|
||||
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
|
||||
logger.info('Performing login');
|
||||
const strategy = injector.get(LoginStrategy);
|
||||
await strategy.login();
|
||||
return;
|
||||
}
|
||||
|
||||
statusElement.innerHTML = 'Native Container wird initialisiert...';
|
||||
logger.info('Initializing native container');
|
||||
const nativeContainer = injector.get(NativeContainerService);
|
||||
await nativeContainer.init();
|
||||
logger.info('Native container initialized');
|
||||
|
||||
statusElement.innerHTML = 'Datenbank wird initialisiert...';
|
||||
logger.info('Initializing database');
|
||||
await injector.get(IDBStorageProvider).init();
|
||||
logger.info('Database initialized');
|
||||
|
||||
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
|
||||
logger.info('Loading user storage');
|
||||
const userStorage = injector.get(UserStorageProvider);
|
||||
await userStorage.init();
|
||||
|
||||
@@ -144,16 +161,29 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
|
||||
const state = userStorage.get('store');
|
||||
if (state && state['version'] === version) {
|
||||
store.dispatch({ type: 'HYDRATE', payload: userStorage.get('store') });
|
||||
logger.info('Store hydrated from user storage');
|
||||
} else {
|
||||
logger.debug('Store hydration skipped', () => ({
|
||||
reason: state ? 'version mismatch' : 'no stored state',
|
||||
}));
|
||||
}
|
||||
// Subscribe on Store changes and save to user storage
|
||||
store.pipe(debounceTime(1000)).subscribe((state) => {
|
||||
userStorage.set('store', { ...state, version });
|
||||
});
|
||||
auth.initialized$
|
||||
.pipe(
|
||||
filter((initialized) => initialized),
|
||||
switchMap(() => store.pipe(debounceTime(1000))),
|
||||
)
|
||||
.subscribe((state) => {
|
||||
userStorage.set('store', state);
|
||||
});
|
||||
|
||||
logger.info('Application initialization completed');
|
||||
// Inject tab navigation service to initialize it
|
||||
injector.get(TabNavigationService).init();
|
||||
} catch (error) {
|
||||
console.error('Error during app initialization', error);
|
||||
logger.error('Application initialization failed', error as Error, () => ({
|
||||
message: (error as Error).message,
|
||||
}));
|
||||
laoderElement.remove();
|
||||
statusElement.classList.add('text-xl');
|
||||
statusElement.innerHTML +=
|
||||
@@ -199,7 +229,7 @@ export function _notificationsHubOptionsFactory(
|
||||
}
|
||||
|
||||
const USER_SUB_FACTORY = () => {
|
||||
const _logger = logger(() => ({
|
||||
const _logger = loggerFactory(() => ({
|
||||
context: 'USER_SUB',
|
||||
}));
|
||||
const auth = inject(OAuthService);
|
||||
|
||||
@@ -8,24 +8,25 @@ import {
|
||||
} from '@angular/common/http';
|
||||
import { from, NEVER, Observable, throwError } from 'rxjs';
|
||||
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
|
||||
import { LoginStrategy } from '@core/auth';
|
||||
import { IsaLogProvider } from '../providers';
|
||||
import { LogLevel } from '@core/logger';
|
||||
import { AuthService, LoginStrategy } from '@core/auth';
|
||||
import { injectOnline$ } from '../services/network-status.service';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
@Injectable()
|
||||
export class HttpErrorInterceptor implements HttpInterceptor {
|
||||
readonly offline$ = injectOnline$().pipe(filter((online) => !online));
|
||||
readonly injector = inject(Injector);
|
||||
|
||||
constructor(private _isaLogProvider: IsaLogProvider) {}
|
||||
#logger = logger(() => ({
|
||||
'http-interceptor': 'HttpErrorInterceptor',
|
||||
}));
|
||||
#offline$ = injectOnline$().pipe(filter((online) => !online));
|
||||
#injector = inject(Injector);
|
||||
#auth = inject(AuthService);
|
||||
|
||||
intercept(
|
||||
req: HttpRequest<any>,
|
||||
next: HttpHandler,
|
||||
): Observable<HttpEvent<any>> {
|
||||
return next.handle(req).pipe(
|
||||
takeUntil(this.offline$),
|
||||
takeUntil(this.#offline$),
|
||||
catchError((error: HttpErrorResponse, caught: any) =>
|
||||
this.handleError(error),
|
||||
),
|
||||
@@ -33,18 +34,22 @@ export class HttpErrorInterceptor implements HttpInterceptor {
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse): Observable<any> {
|
||||
if (error.status === 401) {
|
||||
const strategy = this.injector.get(LoginStrategy);
|
||||
return this.#auth.initialized$.pipe(
|
||||
mergeMap((initialized) => {
|
||||
if (initialized && error.status === 401) {
|
||||
const strategy = this.#injector.get(LoginStrategy);
|
||||
|
||||
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
|
||||
mergeMap(() => NEVER),
|
||||
);
|
||||
}
|
||||
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
|
||||
mergeMap(() => NEVER),
|
||||
);
|
||||
}
|
||||
|
||||
if (!error.url.endsWith('/isa/logging')) {
|
||||
this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error);
|
||||
}
|
||||
if (!error.url.endsWith('/isa/logging')) {
|
||||
this.#logger.error('Http Error', error);
|
||||
}
|
||||
|
||||
return throwError(error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Config } from '@core/config';
|
||||
import { isNullOrUndefined } from '@utils/common';
|
||||
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
|
||||
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
/**
|
||||
* Storage key for the URL to redirect to after login
|
||||
@@ -15,9 +16,16 @@ const REDIRECT_URL_KEY = 'auth_redirect_url';
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly _initialized = new BehaviorSubject<boolean>(false);
|
||||
#logger = logger(() => ({ service: 'AuthService' }));
|
||||
|
||||
#initialized = new BehaviorSubject<boolean>(false);
|
||||
get initialized$() {
|
||||
return this._initialized.asObservable();
|
||||
return this.#initialized.asObservable();
|
||||
}
|
||||
|
||||
#authenticated = new BehaviorSubject<boolean>(false);
|
||||
get authenticated$() {
|
||||
return this.#authenticated.asObservable();
|
||||
}
|
||||
|
||||
private _authConfig: AuthConfig;
|
||||
@@ -27,15 +35,19 @@ export class AuthService {
|
||||
) {
|
||||
this._oAuthService.events?.subscribe((event) => {
|
||||
if (event.type === 'token_received') {
|
||||
console.log(
|
||||
'SSO Token Expiration:',
|
||||
new Date(this._oAuthService.getAccessTokenExpiration()),
|
||||
);
|
||||
this.#logger.info('SSO token received', () => ({
|
||||
tokenExpiration: new Date(
|
||||
this._oAuthService.getAccessTokenExpiration(),
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
// Handle redirect after successful authentication
|
||||
setTimeout(() => {
|
||||
const redirectUrl = this._getAndClearRedirectUrl();
|
||||
if (redirectUrl) {
|
||||
this.#logger.debug('Redirecting after authentication', () => ({
|
||||
redirectUrl,
|
||||
}));
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}, 100);
|
||||
@@ -44,50 +56,71 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this._initialized.getValue()) {
|
||||
if (this.#initialized.getValue()) {
|
||||
this.#logger.error(
|
||||
'AuthService initialization attempted twice',
|
||||
new Error('Already initialized'),
|
||||
);
|
||||
throw new Error('AuthService is already initialized');
|
||||
}
|
||||
|
||||
this.#logger.info('Initializing AuthService');
|
||||
|
||||
this._authConfig = this._config.get('@core/auth');
|
||||
this.#logger.debug('Auth config loaded', () => ({
|
||||
issuer: this._authConfig.issuer,
|
||||
clientId: this._authConfig.clientId,
|
||||
scope: this._authConfig.scope,
|
||||
}));
|
||||
|
||||
this._authConfig.redirectUri = window.location.origin;
|
||||
|
||||
this._authConfig.silentRefreshRedirectUri =
|
||||
window.location.origin + '/silent-refresh.html';
|
||||
this._authConfig.useSilentRefresh = true;
|
||||
|
||||
this.#logger.debug('Auth URIs configured', () => ({
|
||||
redirectUri: this._authConfig.redirectUri,
|
||||
silentRefreshRedirectUri: this._authConfig.silentRefreshRedirectUri,
|
||||
}));
|
||||
|
||||
this._oAuthService.configure(this._authConfig);
|
||||
this._oAuthService.tokenValidationHandler = new JwksValidationHandler();
|
||||
|
||||
this.#logger.debug('Setting up automatic silent refresh');
|
||||
this._oAuthService.setupAutomaticSilentRefresh();
|
||||
|
||||
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
|
||||
this.#logger.debug('Loading discovery document and attempting login');
|
||||
const authenticated =
|
||||
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
|
||||
|
||||
if (!this._oAuthService.getAccessToken()) {
|
||||
throw new Error('No access token. User is not authenticated.');
|
||||
}
|
||||
this.#authenticated.next(authenticated);
|
||||
this.#logger.info('AuthService initialized', () => ({ authenticated }));
|
||||
|
||||
this._initialized.next(true);
|
||||
this.#initialized.next(true);
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return this.isIdTokenValid();
|
||||
return this.#authenticated.getValue();
|
||||
}
|
||||
|
||||
isIdTokenValid() {
|
||||
console.log(
|
||||
'ID Token Expiration:',
|
||||
new Date(this._oAuthService.getIdTokenExpiration()),
|
||||
);
|
||||
return this._oAuthService.hasValidIdToken();
|
||||
const expiration = new Date(this._oAuthService.getIdTokenExpiration());
|
||||
const isValid = this._oAuthService.hasValidIdToken();
|
||||
this.#logger.debug('ID token validation check', () => ({
|
||||
expiration: expiration.toISOString(),
|
||||
isValid,
|
||||
}));
|
||||
return isValid;
|
||||
}
|
||||
|
||||
isAccessTokenValid() {
|
||||
console.log(
|
||||
'ACCESS Token Expiration:',
|
||||
new Date(this._oAuthService.getAccessTokenExpiration()),
|
||||
);
|
||||
return this._oAuthService.hasValidAccessToken();
|
||||
const expiration = new Date(this._oAuthService.getAccessTokenExpiration());
|
||||
const isValid = this._oAuthService.hasValidAccessToken();
|
||||
this.#logger.debug('Access token validation check', () => ({
|
||||
expiration: expiration.toISOString(),
|
||||
isValid,
|
||||
}));
|
||||
return isValid;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
@@ -111,6 +144,7 @@ export class AuthService {
|
||||
if (isNullOrUndefined(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
@@ -135,18 +169,22 @@ export class AuthService {
|
||||
}
|
||||
|
||||
login() {
|
||||
this.#logger.info('Initiating login flow');
|
||||
this._saveRedirectUrl();
|
||||
this._oAuthService.initLoginFlow();
|
||||
}
|
||||
|
||||
setKeyCardToken(token: string) {
|
||||
this.#logger.debug('Setting keycard token');
|
||||
this._oAuthService.customQueryParams = {
|
||||
temp_token: token,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
this.#logger.info('Initiating logout');
|
||||
await this._oAuthService.revokeTokenAndLogout();
|
||||
this.#logger.info('Logout completed');
|
||||
}
|
||||
|
||||
hasRole(role: string | string[]) {
|
||||
@@ -163,16 +201,20 @@ export class AuthService {
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
this.#logger.debug('Refreshing authentication token');
|
||||
|
||||
if (
|
||||
this._authConfig.responseType.includes('code') &&
|
||||
this._authConfig.scope.includes('offline_access')
|
||||
) {
|
||||
await this._oAuthService.refreshToken();
|
||||
this.#logger.info('Token refreshed using refresh token');
|
||||
} else {
|
||||
await this._oAuthService.silentRefresh();
|
||||
this.#logger.info('Token refreshed using silent refresh');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.#logger.error('Token refresh failed', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
|
||||
|
||||
@Pipe({
|
||||
name: 'lineType',
|
||||
@@ -7,8 +8,8 @@ import { Pipe, PipeTransform } from '@angular/core';
|
||||
})
|
||||
export class LineTypePipe implements PipeTransform {
|
||||
transform(value: string, ...args: any[]): 'text' | 'reihe' {
|
||||
const REIHE_REGEX = /^Reihe:\s*"(.+)\"$/g;
|
||||
const reihe = REIHE_REGEX.exec(value)?.[1];
|
||||
const REIHE_REGEX = new RegExp(`^${REIHE_PREFIX_PATTERN}:\\s*"(.+)"$`, 'g');
|
||||
const reihe = REIHE_REGEX.exec(value)?.[2];
|
||||
return reihe ? 'reihe' : 'text';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
OnDestroy,
|
||||
Pipe,
|
||||
PipeTransform,
|
||||
} from '@angular/core';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Subscription, combineLatest, BehaviorSubject } from 'rxjs';
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
|
||||
|
||||
@Pipe({
|
||||
name: 'reiheRoute',
|
||||
@@ -22,10 +28,13 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
|
||||
private application: ApplicationService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) {
|
||||
this.subscription = combineLatest([this.application.activatedProcessId$, this.value$])
|
||||
this.subscription = combineLatest([
|
||||
this.application.activatedProcessId$,
|
||||
this.value$,
|
||||
])
|
||||
.pipe(distinctUntilChanged(isEqual))
|
||||
.subscribe(([processId, value]) => {
|
||||
const REIHE_REGEX = /[";]|Reihe:/g; // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:
|
||||
const REIHE_REGEX = new RegExp(`[";]|${REIHE_PREFIX_PATTERN}:`, 'g'); // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:/Reihe/Set:/Set/Reihe:
|
||||
const reihe = value?.replace(REIHE_REGEX, '')?.trim();
|
||||
|
||||
if (!reihe) {
|
||||
@@ -33,9 +42,15 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const main_qs = reihe.split('/')[0];
|
||||
// Entferne Zahlen am Ende, die mit Leerzeichen, Komma, Slash oder Semikolon getrennt sind
|
||||
// Beispiele: "Harry Potter 1" -> "Harry Potter", "Harry Potter,1" -> "Harry Potter", "Harry Potter/2" -> "Harry Potter"
|
||||
const main_qs = reihe
|
||||
.split('/')[0]
|
||||
.replace(/[\s,;]+\d+$/g, '')
|
||||
.trim();
|
||||
|
||||
const path = this.navigation.getArticleSearchResultsPath(processId).path;
|
||||
const path =
|
||||
this.navigation.getArticleSearchResultsPath(processId).path;
|
||||
|
||||
this.result = {
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared regex pattern for matching Reihe line prefixes.
|
||||
* Matches: "Reihe:", "Reihe/Set:", or "Set/Reihe:"
|
||||
*/
|
||||
export const REIHE_PREFIX_PATTERN = '(Reihe|Reihe\\/Set|Set\\/Reihe)';
|
||||
@@ -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,18 @@ export class CustomerDetailsViewMainComponent
|
||||
|
||||
await this._updateNotifcationChannelsAsync(currentBuyer);
|
||||
|
||||
this._setPayer();
|
||||
await this._setPayer();
|
||||
|
||||
this._setShippingAddress();
|
||||
await this._setShippingAddress();
|
||||
|
||||
// #5461 Priority fix: Check for regular shopping cart items BEFORE reward return URL
|
||||
// This ensures that if a user has items in their regular cart, that takes precedence
|
||||
// over any lingering reward flow context
|
||||
if (this.shoppingCartHasItems) {
|
||||
await this.#rewardSelectionPopUpFlow(this.processId);
|
||||
this.setIsBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// #5262 Check for reward selection flow before navigation
|
||||
if (this.hasReturnUrl()) {
|
||||
@@ -429,16 +446,11 @@ export class CustomerDetailsViewMainComponent
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular checkout navigation
|
||||
if (this.shoppingCartHasItems) {
|
||||
await this.#rewardSelectionPopUpFlow(this.processId);
|
||||
} else {
|
||||
// Navigation zur Artikelsuche
|
||||
const path = this._catalogNavigation.getArticleSearchBasePath(
|
||||
this.processId,
|
||||
).path;
|
||||
await this._router.navigate(path);
|
||||
}
|
||||
// Navigation zur Artikelsuche
|
||||
const path = this._catalogNavigation.getArticleSearchBasePath(
|
||||
this.processId,
|
||||
).path;
|
||||
await this._router.navigate(path);
|
||||
|
||||
this.setIsBusy(false);
|
||||
}
|
||||
@@ -631,8 +643,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 +691,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,
|
||||
|
||||
@@ -1,113 +1,151 @@
|
||||
<div class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4">
|
||||
<div>
|
||||
<img class="rounded shadow mx-auto w-[5.9rem]" [src]="orderItem?.product?.ean | productImage" [alt]="orderItem?.product?.name" />
|
||||
</div>
|
||||
<div class="grid grid-flow-row gap-2">
|
||||
<div class="grid grid-flow-col justify-between items-end">
|
||||
<span>{{ orderItem.product?.contributors }}</span>
|
||||
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
|
||||
<a
|
||||
[routerLink]="orderDetailsHistoryRoute.path"
|
||||
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
|
||||
[queryParamsHandling]="'merge'"
|
||||
class="text-brand font-bold text-xl"
|
||||
>
|
||||
Historie
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="font-bold text-lg">
|
||||
{{ orderItem?.product?.name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="isa-label">
|
||||
{{ processingStatus$ | async | orderItemProcessingStatus }}
|
||||
</span>
|
||||
</div>
|
||||
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
|
||||
<div class="grid grid-flow-row gap-2">
|
||||
<div class="col-data">
|
||||
<div class="col-label">Menge</div>
|
||||
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Format</div>
|
||||
<div class="col-value grid-flow-col grid gap-3 items-center justify-start">
|
||||
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
|
||||
<span>{{ orderItem?.product?.formatDetail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">ISBN/EAN</div>
|
||||
<div class="col-value">{{ orderItem?.product?.ean }}</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Preis</div>
|
||||
<div class="col-value">{{ orderItem?.unitPrice?.value?.value | currency: orderItem?.unitPrice?.value?.currency : 'code' }}</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">MwSt</div>
|
||||
<div class="col-value">{{ orderItem?.unitPrice?.vat?.inPercent }}%</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="col-data">
|
||||
<div class="col-label">Lieferant</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.supplier?.data?.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Meldenummer</div>
|
||||
<div class="col-value">{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Vsl. Lieferdatum</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy' }}
|
||||
</div>
|
||||
</div>
|
||||
@if (orderItemSubsetItem?.preferredPickUpDate) {
|
||||
<div class="col-data">
|
||||
<div class="col-label">Zurücklegen bis</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<hr />
|
||||
@if (orderItemSubsetItem?.compartmentCode) {
|
||||
<div class="col-data">
|
||||
<div class="col-label">Abholfachnummer</div>
|
||||
<div class="col-value">
|
||||
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
|
||||
@if (orderItemSubsetItem?.compartmentInfo) {
|
||||
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="col-data">
|
||||
<div class="col-label">Vormerker</div>
|
||||
<div class="col-value">{{ isPrebooked$ | async }}</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="col-data">
|
||||
<div class="col-label">Zahlungsweg</div>
|
||||
<div class="col-value">-</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Zahlungsart</div>
|
||||
<div class="col-value">
|
||||
{{ orderPaymentType$ | async | paymentType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Anmerkung</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.specialComment || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2 justify-start items-center">
|
||||
@let ean = orderItem?.product?.ean;
|
||||
@let name = orderItem?.product?.name;
|
||||
@if (ean && name) {
|
||||
<img
|
||||
class="rounded shadow mx-auto w-[5.9rem]"
|
||||
[src]="ean | productImage"
|
||||
[alt]="name"
|
||||
/>
|
||||
}
|
||||
@if (hasRewardPoints$ | async) {
|
||||
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
|
||||
Prämie
|
||||
</ui-label>
|
||||
}
|
||||
</div>
|
||||
<div class="grid grid-flow-row gap-2">
|
||||
<div class="grid grid-flow-col justify-between items-end">
|
||||
<span>{{ orderItem.product?.contributors }}</span>
|
||||
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
|
||||
<a
|
||||
[routerLink]="orderDetailsHistoryRoute.path"
|
||||
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
|
||||
[queryParamsHandling]="'merge'"
|
||||
class="text-brand font-bold text-xl"
|
||||
>
|
||||
Historie
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="font-bold text-lg">
|
||||
{{ orderItem?.product?.name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="isa-label">
|
||||
{{ processingStatus$ | async | orderItemProcessingStatus }}
|
||||
</span>
|
||||
</div>
|
||||
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
|
||||
<div class="grid grid-flow-row gap-2">
|
||||
<div class="col-data">
|
||||
<div class="col-label">Menge</div>
|
||||
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Format</div>
|
||||
<div
|
||||
class="col-value grid-flow-col grid gap-3 items-center justify-start"
|
||||
>
|
||||
@let format = orderItem?.product?.format;
|
||||
@if (format) {
|
||||
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
|
||||
}
|
||||
<span>{{ orderItem?.product?.formatDetail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">ISBN/EAN</div>
|
||||
<div class="col-value">{{ orderItem?.product?.ean }}</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
@if (hasRewardPoints$ | async) {
|
||||
<div class="col-label">Prämie</div>
|
||||
<div class="col-value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
|
||||
} @else {
|
||||
<div class="col-label">Preis</div>
|
||||
<div class="col-value">
|
||||
{{
|
||||
orderItem?.unitPrice?.value?.value
|
||||
| currency: orderItem?.unitPrice?.value?.currency : 'code'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">MwSt</div>
|
||||
<div class="col-value">
|
||||
{{ orderItem?.unitPrice?.vat?.inPercent }}%
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="col-data">
|
||||
<div class="col-label">Lieferant</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.supplier?.data?.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Meldenummer</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Vsl. Lieferdatum</div>
|
||||
<div class="col-value">
|
||||
{{
|
||||
orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@if (orderItemSubsetItem?.preferredPickUpDate) {
|
||||
<div class="col-data">
|
||||
<div class="col-label">Zurücklegen bis</div>
|
||||
<div class="col-value">
|
||||
{{
|
||||
orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<hr />
|
||||
@if (orderItemSubsetItem?.compartmentCode) {
|
||||
<div class="col-data">
|
||||
<div class="col-label">Abholfachnummer</div>
|
||||
<div class="col-value">
|
||||
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
|
||||
@if (orderItemSubsetItem?.compartmentInfo) {
|
||||
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="col-data">
|
||||
<div class="col-label">Vormerker</div>
|
||||
<div class="col-value">{{ isPrebooked$ | async }}</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="col-data">
|
||||
<div class="col-label">Zahlungsweg</div>
|
||||
<div class="col-value">-</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Zahlungsart</div>
|
||||
<div class="col-value">
|
||||
{{ orderPaymentType$ | async | paymentType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-data">
|
||||
<div class="col-label">Anmerkung</div>
|
||||
<div class="col-value">
|
||||
{{ orderItemSubsetItem?.specialComment || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,88 +1,136 @@
|
||||
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, Input, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ProductImagePipe } from '@cdn/product-image';
|
||||
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
|
||||
import { OrderItemDTO } from '@generated/swagger/oms-api';
|
||||
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { CustomerSearchStore } from '../../store';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { PaymentTypePipe } from '@shared/pipes/customer';
|
||||
|
||||
@Component({
|
||||
selector: 'page-customer-order-item-list-item',
|
||||
templateUrl: 'order-item-list-item.component.html',
|
||||
styleUrls: ['order-item-list-item.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-customer-order-item-list-item' },
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
DatePipe,
|
||||
ProductImagePipe,
|
||||
CurrencyPipe,
|
||||
RouterLink,
|
||||
PaymentTypePipe,
|
||||
OrderItemProcessingStatusPipe
|
||||
],
|
||||
})
|
||||
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
|
||||
private _activatedRoute = inject(ActivatedRoute);
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
|
||||
private _onDestroy = new Subject<void>();
|
||||
|
||||
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
|
||||
|
||||
@Input()
|
||||
get orderItem() {
|
||||
return this._orderItemSub.getValue();
|
||||
}
|
||||
|
||||
set orderItem(value: OrderItemDTO) {
|
||||
this._orderItemSub.next(value);
|
||||
}
|
||||
orderId$ = this._activatedRoute.params.pipe(map((params) => Number(params.orderId)));
|
||||
|
||||
order$ = this._store.order$;
|
||||
|
||||
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
|
||||
|
||||
customerId$ = this._activatedRoute.params.pipe(map((params) => Number(params.customerId)));
|
||||
|
||||
orderItemOrderType$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.features?.orderType));
|
||||
|
||||
orderItemSubsetItem$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.subsetItems?.[0]?.data));
|
||||
|
||||
orderDetailsHistoryRoute$ = combineLatest([
|
||||
this.customerId$,
|
||||
this._store.processId$,
|
||||
this.orderId$,
|
||||
this._orderItemSub,
|
||||
]).pipe(
|
||||
map(([customerId, processId, orderId, orderItem]) =>
|
||||
this._navigation.orderDetailsHistoryRoute({ processId, customerId, orderId, orderItemId: orderItem?.id }),
|
||||
),
|
||||
);
|
||||
|
||||
isPrebooked$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')));
|
||||
|
||||
processingStatus$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => subsetItem?.processingStatus));
|
||||
|
||||
ngOnInit() {
|
||||
this.customerId$.pipe(takeUntil(this._onDestroy)).subscribe((customerId) => {
|
||||
this._store.selectCustomer({ customerId });
|
||||
});
|
||||
|
||||
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
|
||||
this._store.selectOrder(+orderId);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._onDestroy.next();
|
||||
this._onDestroy.complete();
|
||||
this._orderItemSub.complete();
|
||||
}
|
||||
}
|
||||
import {
|
||||
AsyncPipe,
|
||||
CurrencyPipe,
|
||||
DatePipe,
|
||||
DecimalPipe,
|
||||
} from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ProductImagePipe } from '@cdn/product-image';
|
||||
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
|
||||
import { OrderItemDTO } from '@generated/swagger/oms-api';
|
||||
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { CustomerSearchStore } from '../../store';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { PaymentTypePipe } from '@shared/pipes/customer';
|
||||
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
|
||||
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
|
||||
import { IconComponent } from '@shared/components/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'page-customer-order-item-list-item',
|
||||
templateUrl: 'order-item-list-item.component.html',
|
||||
styleUrls: ['order-item-list-item.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-customer-order-item-list-item' },
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
DatePipe,
|
||||
ProductImagePipe,
|
||||
CurrencyPipe,
|
||||
RouterLink,
|
||||
PaymentTypePipe,
|
||||
OrderItemProcessingStatusPipe,
|
||||
LabelComponent,
|
||||
IconComponent,
|
||||
DecimalPipe,
|
||||
],
|
||||
})
|
||||
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
|
||||
private _activatedRoute = inject(ActivatedRoute);
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
|
||||
private _onDestroy = new Subject<void>();
|
||||
|
||||
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
|
||||
|
||||
@Input()
|
||||
get orderItem() {
|
||||
return this._orderItemSub.getValue();
|
||||
}
|
||||
|
||||
set orderItem(value: OrderItemDTO) {
|
||||
this._orderItemSub.next(value);
|
||||
}
|
||||
orderId$ = this._activatedRoute.params.pipe(
|
||||
map((params) => Number(params.orderId)),
|
||||
);
|
||||
|
||||
order$ = this._store.order$;
|
||||
|
||||
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
|
||||
|
||||
customerId$ = this._activatedRoute.params.pipe(
|
||||
map((params) => Number(params.customerId)),
|
||||
);
|
||||
|
||||
orderItemOrderType$ = this._orderItemSub.pipe(
|
||||
map((orderItem) => orderItem?.features?.orderType),
|
||||
);
|
||||
|
||||
orderItemSubsetItem$ = this._orderItemSub.pipe(
|
||||
map((orderItem) => orderItem?.subsetItems?.[0]?.data),
|
||||
);
|
||||
|
||||
orderDetailsHistoryRoute$ = combineLatest([
|
||||
this.customerId$,
|
||||
this._store.processId$,
|
||||
this.orderId$,
|
||||
this._orderItemSub,
|
||||
]).pipe(
|
||||
map(([customerId, processId, orderId, orderItem]) =>
|
||||
this._navigation.orderDetailsHistoryRoute({
|
||||
processId,
|
||||
customerId,
|
||||
orderId,
|
||||
orderItemId: orderItem?.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
isPrebooked$ = this.orderItemSubsetItem$.pipe(
|
||||
map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')),
|
||||
);
|
||||
|
||||
processingStatus$ = this.orderItemSubsetItem$.pipe(
|
||||
map((subsetItem) => subsetItem?.processingStatus),
|
||||
);
|
||||
|
||||
hasRewardPoints$ = this._orderItemSub.pipe(
|
||||
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
|
||||
);
|
||||
|
||||
rewardPoints$ = this._orderItemSub.pipe(
|
||||
map((orderItem) => getOrderItemRewardFeature(orderItem)),
|
||||
);
|
||||
|
||||
Labeltype = Labeltype;
|
||||
LabelPriority = LabelPriority;
|
||||
|
||||
ngOnInit() {
|
||||
this.customerId$
|
||||
.pipe(takeUntil(this._onDestroy))
|
||||
.subscribe((customerId) => {
|
||||
this._store.selectCustomer({ customerId });
|
||||
});
|
||||
|
||||
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
|
||||
this._store.selectOrder(+orderId);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._onDestroy.next();
|
||||
this._onDestroy.complete();
|
||||
this._orderItemSub.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
[src]="orderItem.product?.ean | productImage"
|
||||
[alt]="orderItem.product?.name"
|
||||
/>
|
||||
@if (hasRewardPoints$ | async) {
|
||||
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
|
||||
Prämie
|
||||
</ui-label>
|
||||
}
|
||||
</div>
|
||||
<div class="page-pickup-shelf-details-item__details">
|
||||
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
|
||||
@@ -117,10 +122,15 @@
|
||||
<div class="value">{{ orderItem.product?.ean }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (orderItem.price !== undefined) {
|
||||
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
|
||||
<div class="detail">
|
||||
<div class="label">Preis</div>
|
||||
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
|
||||
@if (hasRewardPoints$ | async) {
|
||||
<div class="label">Prämie</div>
|
||||
<div class="value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
|
||||
} @else {
|
||||
<div class="label">Preis</div>
|
||||
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!!orderItem.retailPrice?.vat?.inPercent) {
|
||||
|
||||
@@ -21,6 +21,8 @@ button {
|
||||
}
|
||||
|
||||
.page-pickup-shelf-details-item__thumbnail {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
|
||||
img {
|
||||
@apply rounded shadow-cta w-[3.625rem] max-h-[5.9375rem];
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { AsyncPipe, CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
inject, OnDestroy,
|
||||
inject,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
|
||||
import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image';
|
||||
import { DBHOrderItemListItemDTO, OrderDTO, ReceiptDTO } from '@generated/swagger/oms-api';
|
||||
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
|
||||
import { UiCommonModule } from '@ui/common';
|
||||
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
|
||||
import { UiTooltipModule } from '@ui/tooltip';
|
||||
import { PickupShelfPaymentTypePipe } from '../pipes/payment-type.pipe';
|
||||
import { IconModule } from '@shared/components/icon';
|
||||
@@ -48,6 +50,7 @@ export interface PickUpShelfDetailsItemComponentState {
|
||||
ReactiveFormsModule,
|
||||
CurrencyPipe,
|
||||
DatePipe,
|
||||
DecimalPipe,
|
||||
AsyncPipe,
|
||||
ProductImageModule,
|
||||
TextFieldModule,
|
||||
@@ -56,12 +59,13 @@ export interface PickUpShelfDetailsItemComponentState {
|
||||
UiQuantityDropdownModule,
|
||||
NotificationTypePipe,
|
||||
NavigateOnClickDirective,
|
||||
MatomoModule
|
||||
MatomoModule,
|
||||
LabelComponent
|
||||
],
|
||||
})
|
||||
export class PickUpShelfDetailsItemComponent
|
||||
extends ComponentStore<PickUpShelfDetailsItemComponentState>
|
||||
implements OnInit, OnDestroy
|
||||
implements OnDestroy
|
||||
{
|
||||
private _store = inject(PickupShelfDetailsStore);
|
||||
|
||||
@@ -117,6 +121,22 @@ export class PickUpShelfDetailsItemComponent
|
||||
|
||||
hasSmsNotification$ = this.smsNotificationDates$.pipe(map((dates) => dates?.length > 0));
|
||||
|
||||
/**
|
||||
* Observable that indicates whether the order item has reward points (Lesepunkte).
|
||||
* Returns true if the item has a 'praemie' feature.
|
||||
*/
|
||||
hasRewardPoints$ = this.orderItem$.pipe(
|
||||
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that emits the reward points (Lesepunkte) value for the order item.
|
||||
* Returns the parsed numeric value from the 'praemie' feature, or undefined if not present.
|
||||
*/
|
||||
rewardPoints$ = this.orderItem$.pipe(
|
||||
map((orderItem) => getOrderItemRewardFeature(orderItem)),
|
||||
);
|
||||
|
||||
canChangeQuantity$ = combineLatest([this.orderItem$, this._store.fetchPartial$]).pipe(
|
||||
map(([item, partialPickup]) => ([16, 8192].includes(item?.processingStatus) || partialPickup) && item.quantity > 1),
|
||||
);
|
||||
@@ -167,12 +187,12 @@ export class PickUpShelfDetailsItemComponent
|
||||
return this._store.receipts;
|
||||
}
|
||||
|
||||
readonly receipts$ = this._store.receipts$;
|
||||
|
||||
set receipts(receipts: ReceiptDTO[]) {
|
||||
this._store.updateReceipts(receipts);
|
||||
}
|
||||
|
||||
readonly receipts$ = this._store.receipts$;
|
||||
|
||||
readonly receiptCount$ = this.receipts$.pipe(map((receipts) => receipts?.length));
|
||||
|
||||
specialCommentControl = new UntypedFormControl();
|
||||
@@ -181,7 +201,11 @@ export class PickUpShelfDetailsItemComponent
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
expanded: boolean = false;
|
||||
expanded = false;
|
||||
|
||||
// Expose to template
|
||||
Labeltype = Labeltype;
|
||||
LabelPriority = LabelPriority;
|
||||
|
||||
constructor(private _cdr: ChangeDetectorRef) {
|
||||
super({
|
||||
@@ -189,8 +213,6 @@ export class PickUpShelfDetailsItemComponent
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
ngOnDestroy() {
|
||||
// Remove Prev OrderItem from selected list
|
||||
this._store.selectOrderItem(this.orderItem, false);
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
.page-pickup-shelf-list-item__item-thumbnail {
|
||||
grid-area: thumbnail;
|
||||
@apply flex flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.page-pickup-shelf-list-item__item-image {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[class.page-pickup-shelf-list-item__item-grid-container-main]="primaryOutletActive"
|
||||
[class.page-pickup-shelf-list-item__item-grid-container-secondary]="primaryOutletActive && isItemSelectable === undefined"
|
||||
>
|
||||
<div class="page-pickup-shelf-list-item__item-thumbnail text-center w-[3.125rem] h-[4.9375rem]">
|
||||
<div class="page-pickup-shelf-list-item__item-thumbnail text-center">
|
||||
@if (item?.product?.ean | productImage; as productImage) {
|
||||
<img
|
||||
class="page-pickup-shelf-list-item__item-image w-[3.125rem] max-h-[4.9375rem]"
|
||||
@@ -20,6 +20,11 @@
|
||||
[alt]="item?.product?.name"
|
||||
/>
|
||||
}
|
||||
@if (hasRewardPoints) {
|
||||
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
|
||||
Prämie
|
||||
</ui-label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,8 @@ import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { IconModule } from '@shared/components/icon';
|
||||
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
|
||||
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
|
||||
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
|
||||
import { UiCommonModule } from '@ui/common';
|
||||
import { PickupShelfProcessingStatusPipe } from '../pipes/processing-status.pipe';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -29,7 +31,8 @@ import { MatomoModule } from 'ngx-matomo-client';
|
||||
UiCommonModule,
|
||||
PickupShelfProcessingStatusPipe,
|
||||
NavigateOnClickDirective,
|
||||
MatomoModule
|
||||
MatomoModule,
|
||||
LabelComponent
|
||||
],
|
||||
providers: [PickupShelfProcessingStatusPipe],
|
||||
})
|
||||
@@ -77,12 +80,24 @@ export class PickUpShelfListItemComponent {
|
||||
return { 'background-color': this._processingStatusPipe.transform(this.item?.processingStatus, true) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the order item has reward points (Lesepunkte).
|
||||
* Returns true if the item has a 'praemie' feature.
|
||||
*/
|
||||
get hasRewardPoints() {
|
||||
return getOrderItemRewardFeature(this.item) !== undefined;
|
||||
}
|
||||
|
||||
selected$ = this.store.selectedListItems$.pipe(
|
||||
map((selectedListItems) =>
|
||||
selectedListItems?.find((item) => item?.orderItemSubsetId === this.item?.orderItemSubsetId),
|
||||
),
|
||||
);
|
||||
|
||||
// Expose to template
|
||||
Labeltype = Labeltype;
|
||||
LabelPriority = LabelPriority;
|
||||
|
||||
constructor(
|
||||
private _elRef: ElementRef,
|
||||
private _environment: EnvironmentService,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NavigationRoute } from './defs/navigation-route';
|
||||
import {
|
||||
encodeFormData,
|
||||
mapCustomerInfoDtoToCustomerCreateFormData,
|
||||
} from 'apps/isa-app/src/page/customer';
|
||||
} from '@page/customer';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CustomerCreateNavigation {
|
||||
@@ -58,7 +58,7 @@ export class CustomerCreateNavigation {
|
||||
},
|
||||
];
|
||||
|
||||
let formData = params?.customerInfo
|
||||
const formData = params?.customerInfo
|
||||
? encodeFormData(
|
||||
mapCustomerInfoDtoToCustomerCreateFormData(params.customerInfo),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ variables:
|
||||
value: '4'
|
||||
# Minor Version einstellen
|
||||
- name: 'Minor'
|
||||
value: '2'
|
||||
value: '3'
|
||||
- name: 'Patch'
|
||||
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
|
||||
- name: 'BuildUniqueID'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Library Reference Guide
|
||||
|
||||
> **Last Updated:** 2025-10-27
|
||||
> **Last Updated:** 2025-01-10
|
||||
> **Angular Version:** 20.1.2
|
||||
> **Nx Version:** 21.3.2
|
||||
> **Total Libraries:** 62
|
||||
> **Total Libraries:** 63
|
||||
|
||||
All 62 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
|
||||
All 63 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
|
||||
|
||||
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
|
||||
|
||||
@@ -82,7 +82,14 @@ A comprehensive print management library for Angular applications providing prin
|
||||
|
||||
---
|
||||
|
||||
## Core Libraries (5 libraries)
|
||||
## Core Libraries (6 libraries)
|
||||
|
||||
### `@isa/core/auth`
|
||||
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application. Provides Role enum, RoleService for programmatic checks, and IfRoleDirective for declarative template rendering with automatic JWT token parsing via OAuthService.
|
||||
|
||||
**Location:** `libs/core/auth/`
|
||||
**Testing:** Vitest (18 passing tests)
|
||||
**Features:** Signal-based reactivity, type-safe Role enum, zero-configuration OAuth2 integration
|
||||
|
||||
### `@isa/core/config`
|
||||
A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasLoyaltyCollectCommand } from './has-loyalty-collect-command.helper';
|
||||
import { DisplayOrderItemSubset } from '@isa/oms/data-access';
|
||||
|
||||
describe('hasLoyaltyCollectCommand', () => {
|
||||
describe('when items is undefined', () => {
|
||||
it('should return false', () => {
|
||||
// Act
|
||||
const result = hasLoyaltyCollectCommand(undefined);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when items is empty array', () => {
|
||||
it('should return false', () => {
|
||||
// Arrange
|
||||
const items: DisplayOrderItemSubset[] = [];
|
||||
|
||||
// Act
|
||||
const result = hasLoyaltyCollectCommand(items);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when items have no actions', () => {
|
||||
it('should return false', () => {
|
||||
// Arrange
|
||||
const items: DisplayOrderItemSubset[] = [
|
||||
{
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
} as DisplayOrderItemSubset,
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = hasLoyaltyCollectCommand(items);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when items have actions but no LOYALTY_COLLECT_COMMAND', () => {
|
||||
it('should return false', () => {
|
||||
// Arrange
|
||||
const items: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
actions: [
|
||||
{ command: 'SOME_OTHER_COMMAND', label: 'Other Action' },
|
||||
{ command: 'ANOTHER_COMMAND', label: 'Another Action' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = hasLoyaltyCollectCommand(items);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when items have LOYALTY_COLLECT_COMMAND action', () => {
|
||||
it('should return true', () => {
|
||||
// Arrange
|
||||
const items: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
actions: [
|
||||
{
|
||||
command: 'LOYALTY_COLLECT_COMMAND',
|
||||
label: 'Abschließen',
|
||||
selected: true,
|
||||
value: 'Abschließen',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = hasLoyaltyCollectCommand(items);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when items have multiple actions including LOYALTY_COLLECT_COMMAND', () => {
|
||||
it('should return true', () => {
|
||||
// Arrange
|
||||
const items: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
actions: [
|
||||
{ command: 'SOME_OTHER_COMMAND', label: 'Other Action' },
|
||||
{
|
||||
command: 'LOYALTY_COLLECT_COMMAND',
|
||||
label: 'Abschließen',
|
||||
selected: true,
|
||||
value: 'Abschließen',
|
||||
},
|
||||
{ command: 'ANOTHER_COMMAND', label: 'Another Action' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = hasLoyaltyCollectCommand(items);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { DisplayOrderItemSubset } from '@isa/oms/data-access';
|
||||
|
||||
/**
|
||||
* Checks if any of the subset items has a LOYALTY_COLLECT_COMMAND action
|
||||
* @param items - Array of DisplayOrderItemSubset to check
|
||||
* @returns true if at least one item has a LOYALTY_COLLECT_COMMAND action
|
||||
*/
|
||||
export const hasLoyaltyCollectCommand = (
|
||||
items?: DisplayOrderItemSubset[],
|
||||
): boolean => {
|
||||
const firstItem = items?.find((_) => true);
|
||||
return (
|
||||
firstItem?.actions?.some((action) =>
|
||||
action?.command?.includes('LOYALTY_COLLECT_COMMAND'),
|
||||
) ?? false
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './get-order-type-feature.helper';
|
||||
export * from './has-order-type-feature.helper';
|
||||
export * from './has-loyalty-collect-command.helper';
|
||||
export * from './checkout-analysis.helpers';
|
||||
export * from './checkout-business-logic.helpers';
|
||||
export * from './checkout-data.helpers';
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
@@ -11,6 +17,12 @@ import { isaActionChevronRight } from '@isa/icons';
|
||||
*
|
||||
* Shows customer name and a chevron button for navigation to the order completion page.
|
||||
*/
|
||||
|
||||
export type OpenTaskCardInput = Pick<
|
||||
DBHOrderItemListItemDTO,
|
||||
'orderId' | 'firstName' | 'lastName'
|
||||
>;
|
||||
|
||||
@Component({
|
||||
selector: 'reward-catalog-open-task-card',
|
||||
standalone: true,
|
||||
@@ -20,7 +32,7 @@ import { isaActionChevronRight } from '@isa/icons';
|
||||
<a
|
||||
class="bg-isa-white flex items-center justify-between px-[22px] py-[20px] rounded-2xl w-[334px] cursor-pointer no-underline"
|
||||
data-what="open-task-card"
|
||||
[attr.data-which]="task().orderItemId"
|
||||
[attr.data-which]="task().orderId"
|
||||
[routerLink]="routePath()"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -47,7 +59,7 @@ export class OpenTaskCardComponent {
|
||||
/**
|
||||
* The open task data to display
|
||||
*/
|
||||
readonly task = input.required<DBHOrderItemListItemDTO>();
|
||||
readonly task = input.required<OpenTaskCardInput>();
|
||||
|
||||
/**
|
||||
* Computed customer name from first and last name
|
||||
@@ -62,7 +74,9 @@ export class OpenTaskCardComponent {
|
||||
/**
|
||||
* Current tab ID for navigation
|
||||
*/
|
||||
readonly #tabId = computed(() => this.#tabService.activatedTab()?.id ?? Date.now());
|
||||
readonly #tabId = computed(
|
||||
() => this.#tabService.activatedTab()?.id ?? Date.now(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Route path to the reward order confirmation page.
|
||||
@@ -74,6 +88,12 @@ export class OpenTaskCardComponent {
|
||||
console.warn('Missing orderId in task', this.task());
|
||||
return [];
|
||||
}
|
||||
return ['/', this.#tabId(), 'reward', 'order-confirmation', orderId.toString()];
|
||||
return [
|
||||
'/',
|
||||
this.#tabId(),
|
||||
'reward',
|
||||
'order-confirmation',
|
||||
orderId.toString(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { OpenRewardTasksResource } from '@isa/oms/data-access';
|
||||
import { CarouselComponent } from '@isa/ui/carousel';
|
||||
import { OpenTaskCardComponent } from './open-task-card.component';
|
||||
@@ -13,6 +13,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
|
||||
* - Keyboard navigation (Arrow Left/Right)
|
||||
* - Automatic visibility based on task availability
|
||||
* - Shared global resource for consistent data across app
|
||||
* - Deduplicates tasks to show only one card per orderId
|
||||
*/
|
||||
@Component({
|
||||
selector: 'reward-catalog-open-tasks-carousel',
|
||||
@@ -22,7 +23,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
|
||||
@if (openTasksResource.hasOpenTasks()) {
|
||||
<div class="mb-4" data-what="open-tasks-carousel">
|
||||
<ui-carousel [gap]="'1rem'" [arrowAutoHide]="true">
|
||||
@for (task of openTasksResource.tasks(); track task.orderItemId) {
|
||||
@for (task of uniqueTasks(); track task.orderId) {
|
||||
<reward-catalog-open-task-card [task]="task" />
|
||||
}
|
||||
</ui-carousel>
|
||||
@@ -36,4 +37,23 @@ export class OpenTasksCarouselComponent {
|
||||
* Global resource managing open reward tasks data
|
||||
*/
|
||||
readonly openTasksResource = inject(OpenRewardTasksResource);
|
||||
|
||||
/**
|
||||
* Deduplicated tasks - shows only one task per orderId.
|
||||
* When multiple order items exist for the same order, only the first one is displayed.
|
||||
*
|
||||
* @returns Array of unique tasks filtered by orderId
|
||||
*/
|
||||
readonly uniqueTasks = computed(() => {
|
||||
const tasks = this.openTasksResource.tasks();
|
||||
const seenOrderIds = new Set<number>();
|
||||
|
||||
return tasks.filter(task => {
|
||||
if (!task.orderId || seenOrderIds.has(task.orderId)) {
|
||||
return false;
|
||||
}
|
||||
seenOrderIds.add(task.orderId);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export class RewardActionComponent {
|
||||
items,
|
||||
useRedemptionPoints: true,
|
||||
preSelectOption: { option: 'in-store' },
|
||||
disabledPurchaseOptions: ['b2b-delivery'],
|
||||
disabledPurchaseOptions: ['delivery', 'dig-delivery', 'b2b-delivery'],
|
||||
hideDisabledPurchaseOptions: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.confirmation-list-item-done {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@if (displayActionCard()) {
|
||||
<div
|
||||
class="w-72 desktop-large:w-[24.5rem] justify-between h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
|
||||
[class.confirmation-list-item-done]="item().status !== 1"
|
||||
data-which="action-card"
|
||||
data-what="action-card"
|
||||
*ifNotRole="Role.CallCenter"
|
||||
>
|
||||
@if (!isComplete()) {
|
||||
<div
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
import {
|
||||
hasOrderTypeFeature,
|
||||
buildItemQuantityMap,
|
||||
hasLoyaltyCollectCommand,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-confirmation-list-item-action-card',
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
ButtonComponent,
|
||||
DropdownButtonComponent,
|
||||
DropdownOptionComponent,
|
||||
IfRoleDirective,
|
||||
],
|
||||
providers: [
|
||||
provideIcons({ isaActionCheck }),
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
],
|
||||
})
|
||||
export class ConfirmationListItemActionCardComponent {
|
||||
protected readonly Role = Role;
|
||||
LoyaltyCollectType = LoyaltyCollectType;
|
||||
ProcessingStatusState = ProcessingStatusState;
|
||||
#orderRewardCollectFacade = inject(OrderRewardCollectFacade);
|
||||
@@ -89,11 +93,25 @@ export class ConfirmationListItemActionCardComponent {
|
||||
});
|
||||
|
||||
isComplete = computed(() => {
|
||||
return this.processingStatus() !== undefined;
|
||||
return (
|
||||
this.processingStatus() !== undefined &&
|
||||
this.processingStatus() !== ProcessingStatusState.Ordered
|
||||
);
|
||||
});
|
||||
|
||||
displayActionCard = computed(() =>
|
||||
hasOrderTypeFeature(this.item().features, ['Rücklage']),
|
||||
/**
|
||||
* #5459 - Determines whether the action card should be displayed for this order item.
|
||||
*
|
||||
* The action card is shown when ALL of the following conditions are met:
|
||||
* - The item MUST have the 'Rücklage' order type feature
|
||||
* - AND one of the following:
|
||||
* - The item has a loyalty collect command available (for collecting rewards)
|
||||
* - OR the item processing is complete (for displaying the completed state)
|
||||
*/
|
||||
displayActionCard = computed(
|
||||
() =>
|
||||
hasOrderTypeFeature(this.item().features, ['Rücklage']) &&
|
||||
(hasLoyaltyCollectCommand(this.item().subsetItems) || this.isComplete()),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export class RewardShoppingCartItemComponent {
|
||||
tabId: this.#tabId() as unknown as number,
|
||||
type: 'update',
|
||||
useRedemptionPoints: true,
|
||||
disabledPurchaseOptions: ['b2b-delivery'],
|
||||
disabledPurchaseOptions: ['delivery', 'dig-delivery', 'b2b-delivery'],
|
||||
hideDisabledPurchaseOptions: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from '@isa/core/logging';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { isResponseArgs } from '@isa/common/data-access';
|
||||
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
|
||||
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
|
||||
|
||||
/**
|
||||
* Orchestrates checkout completion and order creation.
|
||||
@@ -37,6 +38,7 @@ export class CheckoutCompletionOrchestratorService {
|
||||
#shoppingCartFacade = inject(ShoppingCartFacade);
|
||||
#orderCreationFacade = inject(OrderCreationFacade);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
#errorFeedbackDialog = injectFeedbackErrorDialog();
|
||||
|
||||
/**
|
||||
* Complete checkout with CRM data and create orders.
|
||||
@@ -114,6 +116,12 @@ export class CheckoutCompletionOrchestratorService {
|
||||
) {
|
||||
const responseArgs = error.error;
|
||||
orders = responseArgs.result;
|
||||
this.#errorFeedbackDialog({
|
||||
data: {
|
||||
errorMessage: responseArgs.message,
|
||||
},
|
||||
});
|
||||
|
||||
// Wenn Bestellungen erstellt wurden, loggen wir eine Warnung aber fahren fort
|
||||
if (orders.length > 0) {
|
||||
this.#logger.warn(
|
||||
|
||||
816
libs/core/auth/README.md
Normal file
816
libs/core/auth/README.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# @isa/core/auth
|
||||
|
||||
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [API Reference](#api-reference)
|
||||
- [Role (Enum)](#role-enum)
|
||||
- [RoleService](#roleservice)
|
||||
- [IfRoleDirective](#ifroledirective)
|
||||
- [TokenProvider](#tokenprovider)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [Configuration](#configuration)
|
||||
- [Testing](#testing)
|
||||
- [Architecture](#architecture)
|
||||
- [Dependencies](#dependencies)
|
||||
|
||||
## Overview
|
||||
|
||||
`@isa/core/auth` provides a lightweight, type-safe system for managing role-based authorization in Angular applications. Built with modern Angular patterns (signals, standalone components), it integrates seamlessly with OAuth2 authentication flows.
|
||||
|
||||
### The Problem It Solves
|
||||
|
||||
Traditional role-checking often involves:
|
||||
- ❌ String literals scattered throughout templates and components
|
||||
- ❌ No compile-time safety for role names
|
||||
- ❌ Manual token parsing and claim extraction
|
||||
- ❌ Repetitive conditional rendering logic
|
||||
|
||||
This library provides:
|
||||
- ✅ Type-safe `Role` enum with autocomplete
|
||||
- ✅ Automatic JWT token parsing via `OAuthService`
|
||||
- ✅ Declarative role-based rendering with `*ifRole` directive
|
||||
- ✅ Reactive updates using Angular signals
|
||||
- ✅ Centralized role management
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Type-Safe Roles** - Enum-based role definitions prevent typos
|
||||
- 🎯 **Declarative Templates** - `*ifRole` and `*ifNotRole` structural directives
|
||||
- ⚡ **Signal-Based** - Reactive role checking with Angular signals
|
||||
- 🔄 **Flexible Token Provider** - Injectable abstraction with OAuth2 default
|
||||
- 📝 **Comprehensive Logging** - Integrated with `@isa/core/logging`
|
||||
- 🧪 **Fully Tested** - 18 unit tests with Vitest
|
||||
- 🎨 **Standalone** - No module imports required
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Import the directive and Role enum:**
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<!-- Show content only for Store users -->
|
||||
<div *ifRole="Role.Store">
|
||||
<h2>Store Dashboard</h2>
|
||||
<!-- Store-specific features -->
|
||||
</div>
|
||||
|
||||
<!-- Show content only for CallCenter users -->
|
||||
<div *ifRole="Role.CallCenter">
|
||||
<h2>CallCenter Dashboard</h2>
|
||||
<!-- CallCenter-specific features -->
|
||||
</div>
|
||||
|
||||
<!-- Hide content from CallCenter users -->
|
||||
<div *ifNotRole="Role.CallCenter">
|
||||
<button>Complete Order</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {
|
||||
protected readonly Role = Role; // Expose to template
|
||||
}
|
||||
```
|
||||
|
||||
**2. Use RoleService programmatically:**
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav',
|
||||
template: `...`
|
||||
})
|
||||
export class NavComponent {
|
||||
private readonly roleService = inject(RoleService);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.roleService.hasRole(Role.Store)) {
|
||||
// Enable store-specific navigation
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. No configuration needed!** The library automatically uses `OAuthService` to parse JWT tokens.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Role Enum
|
||||
|
||||
Roles are defined as a const object with TypeScript type safety:
|
||||
|
||||
```typescript
|
||||
export const Role = {
|
||||
CallCenter: 'CallCenter', // HSC (Hugendubel Service Center)
|
||||
Store: 'Store', // Store/Branch users
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Autocomplete in IDEs
|
||||
- Compile-time checking prevents invalid roles
|
||||
- Easy to extend with new roles
|
||||
|
||||
### Token Provider Pattern
|
||||
|
||||
The library uses an injectable `TokenProvider` abstraction to decouple from specific authentication implementations:
|
||||
|
||||
```typescript
|
||||
export interface TokenProvider {
|
||||
getClaimByKey(key: string): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Default Implementation:**
|
||||
- Automatically provided via `InjectionToken` factory
|
||||
- Uses `OAuthService.getAccessToken()` to fetch JWT
|
||||
- Parses token using `parseJwt()` utility
|
||||
- No manual configuration required
|
||||
|
||||
### Signal-Based Reactivity
|
||||
|
||||
The `IfRoleDirective` uses Angular effects for automatic re-rendering when roles change:
|
||||
|
||||
```typescript
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.render(); // Re-render when ifRole/ifNotRole inputs change
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Role (Enum)
|
||||
|
||||
Type-safe role definitions for the application.
|
||||
|
||||
```typescript
|
||||
export const Role = {
|
||||
CallCenter: 'CallCenter', // HSC users
|
||||
Store: 'Store', // Store users
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { Role } from '@isa/core/auth';
|
||||
|
||||
if (roleService.hasRole(Role.Store)) {
|
||||
// Type-safe!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### RoleService
|
||||
|
||||
Service for programmatic role checks.
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `hasRole(role: Role | Role[]): boolean`
|
||||
|
||||
Check if the authenticated user has specific role(s).
|
||||
|
||||
**Parameters:**
|
||||
- `role` - Single role or array of roles to check (AND logic for arrays)
|
||||
|
||||
**Returns:** `true` if user has all specified roles, `false` otherwise
|
||||
|
||||
**Examples:**
|
||||
|
||||
```typescript
|
||||
import { inject } from '@angular/core';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
export class ExampleComponent {
|
||||
private readonly roleService = inject(RoleService);
|
||||
|
||||
checkAccess() {
|
||||
// Single role check
|
||||
if (this.roleService.hasRole(Role.Store)) {
|
||||
console.log('User is a store employee');
|
||||
}
|
||||
|
||||
// Multiple roles (AND logic)
|
||||
if (this.roleService.hasRole([Role.Store, Role.CallCenter])) {
|
||||
console.log('User has BOTH store AND call center access');
|
||||
}
|
||||
|
||||
// Multiple checks
|
||||
const isStore = this.roleService.hasRole(Role.Store);
|
||||
const isCallCenter = this.roleService.hasRole(Role.CallCenter);
|
||||
|
||||
if (isStore || isCallCenter) {
|
||||
console.log('User has at least one role (OR logic)');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Logging:**
|
||||
|
||||
The service logs all role checks at `debug` level:
|
||||
```
|
||||
[RoleService] Role check: Store => true
|
||||
[RoleService] Role check: Store, CallCenter => false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IfRoleDirective
|
||||
|
||||
Structural directive for declarative role-based rendering.
|
||||
|
||||
**Selector:** `[ifRole]`, `[ifRoleElse]`, `[ifNotRole]`, `[ifNotRoleElse]`
|
||||
|
||||
#### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `ifRole` | `Role \| Role[]` | Role(s) required to show template |
|
||||
| `ifRoleElse` | `TemplateRef` | Alternative template if user lacks role |
|
||||
| `ifNotRole` | `Role \| Role[]` | Role(s) that should NOT be present |
|
||||
| `ifNotRoleElse` | `TemplateRef` | Alternative template if user has role |
|
||||
|
||||
#### Examples
|
||||
|
||||
**Basic Usage:**
|
||||
|
||||
```html
|
||||
<!-- Show for Store users -->
|
||||
<div *ifRole="Role.Store">
|
||||
Store-specific content
|
||||
</div>
|
||||
|
||||
<!-- Show for CallCenter users -->
|
||||
<div *ifRole="Role.CallCenter">
|
||||
CallCenter-specific content
|
||||
</div>
|
||||
```
|
||||
|
||||
**With Else Template:**
|
||||
|
||||
```html
|
||||
<div *ifRole="Role.Store; else noAccess">
|
||||
<button>Complete Order</button>
|
||||
</div>
|
||||
|
||||
<ng-template #noAccess>
|
||||
<p>You don't have permission to complete orders</p>
|
||||
</ng-template>
|
||||
```
|
||||
|
||||
**Negation (`ifNotRole`):**
|
||||
|
||||
```html
|
||||
<!-- Hide from CallCenter users -->
|
||||
<div *ifNotRole="Role.CallCenter">
|
||||
<button>Release Reward</button>
|
||||
<button>Mark Not Found</button>
|
||||
<button>Cancel</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Multiple Roles (AND logic):**
|
||||
|
||||
```html
|
||||
<!-- Only show if user has BOTH roles -->
|
||||
<div *ifRole="[Role.Store, Role.CallCenter]">
|
||||
Advanced features requiring both roles
|
||||
</div>
|
||||
```
|
||||
|
||||
**Component Integration:**
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifNotRole="Role.CallCenter">
|
||||
<button (click)="completeOrder()">Complete</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ActionsComponent {
|
||||
// Expose Role to template
|
||||
protected readonly Role = Role;
|
||||
|
||||
completeOrder() {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### TokenProvider
|
||||
|
||||
Injectable abstraction for JWT token parsing.
|
||||
|
||||
```typescript
|
||||
export interface TokenProvider {
|
||||
getClaimByKey(key: string): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Default Implementation:**
|
||||
|
||||
Automatically provided via `InjectionToken` factory:
|
||||
|
||||
```typescript
|
||||
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
|
||||
'TOKEN_PROVIDER',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const oAuthService = inject(OAuthService);
|
||||
return {
|
||||
getClaimByKey: (key: string) => {
|
||||
const claims = parseJwt(oAuthService.getAccessToken());
|
||||
return claims?.[key] ?? null;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Custom Provider (Advanced):**
|
||||
|
||||
Override the default implementation:
|
||||
|
||||
```typescript
|
||||
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
|
||||
|
||||
providers: [
|
||||
{
|
||||
provide: TOKEN_PROVIDER,
|
||||
useValue: {
|
||||
getClaimByKey: (key: string) => {
|
||||
// Custom token parsing logic
|
||||
return myCustomAuthService.getClaim(key);
|
||||
}
|
||||
} as TokenProvider
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### parseJwt()
|
||||
|
||||
Utility function to parse JWT tokens.
|
||||
|
||||
```typescript
|
||||
export function parseJwt(
|
||||
token: string | null
|
||||
): Record<string, unknown> | null
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `token` - JWT token string or null
|
||||
|
||||
**Returns:** Parsed claims object or null
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
import { parseJwt } from '@isa/core/auth';
|
||||
|
||||
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
|
||||
const claims = parseJwt(token);
|
||||
|
||||
console.log(claims?.['role']); // ['Store']
|
||||
console.log(claims?.['sub']); // User ID
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Conditional Navigation
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-menu',
|
||||
standalone: true,
|
||||
imports: [RouterLink, IfRoleDirective],
|
||||
template: `
|
||||
<nav>
|
||||
<!-- Store-only navigation -->
|
||||
<a *ifRole="Role.Store" routerLink="/inventory">
|
||||
Inventory Management
|
||||
</a>
|
||||
|
||||
<a *ifRole="Role.Store" routerLink="/store-orders">
|
||||
Store Orders
|
||||
</a>
|
||||
|
||||
<!-- CallCenter-only navigation -->
|
||||
<a *ifRole="Role.CallCenter" routerLink="/customer-service">
|
||||
Customer Service
|
||||
</a>
|
||||
|
||||
<!-- Show for both roles -->
|
||||
<a routerLink="/dashboard">
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
`
|
||||
})
|
||||
export class SideMenuComponent {
|
||||
protected readonly Role = Role;
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Guard with RoleService
|
||||
|
||||
```typescript
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
export const storeGuard: CanActivateFn = () => {
|
||||
const roleService = inject(RoleService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (roleService.hasRole(Role.Store)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Redirect to unauthorized page
|
||||
return router.createUrlTree(['/unauthorized']);
|
||||
};
|
||||
|
||||
// Route configuration
|
||||
export const routes = [
|
||||
{
|
||||
path: 'inventory',
|
||||
component: InventoryComponent,
|
||||
canActivate: [storeGuard]
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Example 3: Computed Signals with Roles
|
||||
|
||||
```typescript
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
template: `
|
||||
@if (canManageInventory()) {
|
||||
<button (click)="openInventory()">Manage Inventory</button>
|
||||
}
|
||||
|
||||
@if (canProcessReturns()) {
|
||||
<button (click)="openReturns()">Process Returns</button>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {
|
||||
private readonly roleService = inject(RoleService);
|
||||
|
||||
// Computed permissions
|
||||
canManageInventory = computed(() =>
|
||||
this.roleService.hasRole(Role.Store)
|
||||
);
|
||||
|
||||
canProcessReturns = computed(() =>
|
||||
this.roleService.hasRole([Role.Store, Role.CallCenter])
|
||||
);
|
||||
|
||||
openInventory() { /* ... */ }
|
||||
openReturns() { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Real-World Component (Reward Order Confirmation)
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-confirmation-actions',
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective, ButtonComponent],
|
||||
template: `
|
||||
<div class="action-card">
|
||||
<div class="message">
|
||||
Please complete the order or select an action.
|
||||
</div>
|
||||
|
||||
<!-- Hide actions from CallCenter (HSC) users -->
|
||||
<div *ifNotRole="Role.CallCenter" class="actions">
|
||||
<select [(ngModel)]="selectedAction">
|
||||
<option value="collect">Release Reward</option>
|
||||
<option value="not-found">Not Found</option>
|
||||
<option value="cancel">Cancel</option>
|
||||
</select>
|
||||
|
||||
<button uiButton color="primary" (click)="complete()">
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ConfirmationActionsComponent {
|
||||
protected readonly Role = Role;
|
||||
selectedAction = 'collect';
|
||||
|
||||
complete() {
|
||||
// Complete order logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration (Recommended)
|
||||
|
||||
No configuration needed! The library automatically uses `OAuthService`:
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
// Works out of the box!
|
||||
})
|
||||
export class MyComponent {}
|
||||
```
|
||||
|
||||
### Custom TokenProvider (Advanced)
|
||||
|
||||
Override the default token provider:
|
||||
|
||||
```typescript
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{
|
||||
provide: TOKEN_PROVIDER,
|
||||
useFactory: () => {
|
||||
const customAuth = inject(CustomAuthService);
|
||||
return {
|
||||
getClaimByKey: (key: string) => customAuth.getClaim(key)
|
||||
} as TokenProvider;
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### JWT Token Structure
|
||||
|
||||
The library expects JWT tokens with a `role` claim:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user123",
|
||||
"role": ["Store"],
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
- Single role: `"role": "Store"`
|
||||
- Multiple roles: `"role": ["Store", "CallCenter"]`
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npx nx test core-auth
|
||||
|
||||
# Run with coverage
|
||||
npx nx test core-auth --coverage.enabled=true
|
||||
|
||||
# Skip cache (fresh run)
|
||||
npx nx test core-auth --skip-nx-cache
|
||||
```
|
||||
|
||||
### Test Results
|
||||
|
||||
```
|
||||
✓ src/lib/role.service.spec.ts (11 tests)
|
||||
✓ src/lib/if-role.directive.spec.ts (7 tests)
|
||||
|
||||
Test Files 2 passed (2)
|
||||
Tests 18 passed (18)
|
||||
```
|
||||
|
||||
### Testing in Your App
|
||||
|
||||
**Mock RoleService:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
let roleService: RoleService;
|
||||
|
||||
beforeEach(() => {
|
||||
roleService = {
|
||||
hasRole: vi.fn().mockReturnValue(true)
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: RoleService, useValue: roleService }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should show store content for store users', () => {
|
||||
vi.spyOn(roleService, 'hasRole').mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(MyComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(roleService.hasRole).toHaveBeenCalledWith(Role.Store);
|
||||
// Assert UI changes
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Mock TokenProvider:**
|
||||
|
||||
```typescript
|
||||
import { TOKEN_PROVIDER, TokenProvider, Role } from '@isa/core/auth';
|
||||
|
||||
const mockTokenProvider: TokenProvider = {
|
||||
getClaimByKey: vi.fn().mockReturnValue([Role.Store])
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: TOKEN_PROVIDER, useValue: mockTokenProvider }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Patterns
|
||||
|
||||
**1. Token Provider Pattern**
|
||||
- Abstracts JWT parsing behind injectable interface
|
||||
- Allows custom implementations without changing consumers
|
||||
- Default factory provides OAuthService integration
|
||||
|
||||
**2. Signal-Based Reactivity**
|
||||
- Uses Angular signals for reactive role checks
|
||||
- Effect-driven template updates
|
||||
- Minimal re-renders with fine-grained reactivity
|
||||
|
||||
**3. Type-Safe Enum Pattern**
|
||||
- Const object with `as const` assertion
|
||||
- Provides autocomplete and compile-time safety
|
||||
- Prevents typos and invalid role strings
|
||||
|
||||
### Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Components │ │ Route Guards │ │
|
||||
│ │ (Templates) │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ │ *ifRole │ hasRole() │
|
||||
│ ▼ ▼ │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ @isa/core/auth Library │
|
||||
│ ┌──────────────────┐ ┌─────────────────┐ │
|
||||
│ │ IfRoleDirective │ │ RoleService │ │
|
||||
│ │ (Signals) │──────▶│ (Injectable) │ │
|
||||
│ └──────────────────┘ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ hasRole(Role[]) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ TokenProvider │ │
|
||||
│ │ (InjectionToken) │ │
|
||||
│ └────────┬───────────┘ │
|
||||
│ │ │
|
||||
│ getClaimByKey('role') │
|
||||
│ │ │
|
||||
├───────────────────────────────────┼──────────────┤
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ OAuthService │ │
|
||||
│ │ (angular-oauth2-oidc) │ │
|
||||
│ └──────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ getAccessToken() │
|
||||
│ │ │
|
||||
└──────────────────────────┼────────────────────────┘
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ JWT Token │
|
||||
│ { role: ... }│
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Role Claim Handling
|
||||
|
||||
The library handles both single and multiple role formats:
|
||||
|
||||
```typescript
|
||||
// Single role (string)
|
||||
{ "role": "Store" }
|
||||
|
||||
// Multiple roles (array)
|
||||
{ "role": ["Store", "CallCenter"] }
|
||||
|
||||
// Internal normalization using coerceArray()
|
||||
const userRolesArray = coerceArray(userRoles);
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Dependencies
|
||||
|
||||
- **`@angular/core`** - Angular framework
|
||||
- **`@angular/cdk/coercion`** - Array coercion utility
|
||||
- **`angular-oauth2-oidc`** - OAuth2/OIDC authentication
|
||||
- **`@isa/core/logging`** - Logging integration
|
||||
|
||||
### Internal Dependencies
|
||||
|
||||
No other ISA libraries required beyond `@isa/core/logging`.
|
||||
|
||||
### Import Path
|
||||
|
||||
```typescript
|
||||
import {
|
||||
RoleService,
|
||||
IfRoleDirective,
|
||||
Role,
|
||||
TokenProvider,
|
||||
TOKEN_PROVIDER,
|
||||
parseJwt
|
||||
} from '@isa/core/auth';
|
||||
```
|
||||
|
||||
**Path Alias:** `@isa/core/auth` → `libs/core/auth/src/index.ts`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLAUDE.md](../../../CLAUDE.md) - Project guidelines
|
||||
- [Testing Guidelines](../../../docs/guidelines/testing.md) - Vitest setup
|
||||
- [Library Reference](../../../docs/library-reference.md) - All libraries
|
||||
|
||||
## Related Libraries
|
||||
|
||||
- [`@isa/core/logging`](../logging/README.md) - Structured logging
|
||||
- [`@isa/core/config`](../config/README.md) - Configuration management
|
||||
- [`@isa/core/storage`](../storage/README.md) - State persistence
|
||||
|
||||
---
|
||||
|
||||
**License:** ISC
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-01-10
|
||||
34
libs/core/auth/eslint.config.cjs
Normal file
34
libs/core/auth/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/core/auth/project.json
Normal file
20
libs/core/auth/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "core-auth",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/core/auth/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../coverage/libs/core/auth"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
libs/core/auth/src/index.ts
Normal file
10
libs/core/auth/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Core Auth Library
|
||||
*
|
||||
* Provides role-based authorization utilities for the ISA Frontend application.
|
||||
*/
|
||||
|
||||
export { RoleService } from './lib/role.service';
|
||||
export { IfRoleDirective } from './lib/if-role.directive';
|
||||
export { TokenProvider, TOKEN_PROVIDER, parseJwt } from './lib/token-provider';
|
||||
export { Role } from './lib/role';
|
||||
157
libs/core/auth/src/lib/if-role.directive.spec.ts
Normal file
157
libs/core/auth/src/lib/if-role.directive.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { IfRoleDirective } from './if-role.directive';
|
||||
import { RoleService } from './role.service';
|
||||
import { Role } from './role';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifRole="role" data-test="content">Store Content</div>
|
||||
`,
|
||||
})
|
||||
class TestIfRoleComponent {
|
||||
role = Role.Store;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifRole="role; else noAccess" data-test="content">Store Content</div>
|
||||
<ng-template #noAccess>
|
||||
<div data-test="else">No Access</div>
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
class TestIfRoleElseComponent {
|
||||
role = Role.Store;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifNotRole="role" data-test="content">Non-Store Content</div>
|
||||
`,
|
||||
})
|
||||
class TestIfNotRoleComponent {
|
||||
role = Role.Store;
|
||||
}
|
||||
|
||||
describe('IfRoleDirective', () => {
|
||||
let roleService: { hasRole: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
roleService = {
|
||||
hasRole: vi.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: RoleService, useValue: roleService }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('ifRole', () => {
|
||||
it('should render content when user has role', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
expect(content?.textContent).toContain('Store Content');
|
||||
});
|
||||
|
||||
it('should not render content when user does not have role', () => {
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render else template when user does not have role', () => {
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleElseComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
const elseContent = fixture.nativeElement.querySelector('[data-test="else"]');
|
||||
|
||||
expect(content).toBeFalsy();
|
||||
expect(elseContent).toBeTruthy();
|
||||
expect(elseContent?.textContent).toContain('No Access');
|
||||
});
|
||||
|
||||
it('should update when role input changes', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
let content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
|
||||
// Change role and mock to return false
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
fixture.componentInstance.role = Role.CallCenter;
|
||||
fixture.detectChanges();
|
||||
|
||||
content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ifNotRole', () => {
|
||||
it('should render content when user does NOT have role', () => {
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
expect(content?.textContent).toContain('Non-Store Content');
|
||||
});
|
||||
|
||||
it('should not render content when user has role', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple roles', () => {
|
||||
it('should handle array of roles', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `<div *ifRole="roles" data-test="content">Content</div>`,
|
||||
})
|
||||
class TestMultipleRolesComponent {
|
||||
roles = [Role.Store, Role.CallCenter];
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(TestMultipleRolesComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(roleService.hasRole).toHaveBeenCalledWith([Role.Store, Role.CallCenter]);
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
105
libs/core/auth/src/lib/if-role.directive.ts
Normal file
105
libs/core/auth/src/lib/if-role.directive.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Directive,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
TemplateRef,
|
||||
ViewContainerRef,
|
||||
} from '@angular/core';
|
||||
import { RoleService } from './role.service';
|
||||
import { Role } from './role';
|
||||
|
||||
/**
|
||||
* Structural directive for role-based conditional rendering using Angular signals
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <!-- Show content if user has role -->
|
||||
* <div *ifRole="Role.Store">Store content</div>
|
||||
*
|
||||
* <!-- Show content if user has multiple roles -->
|
||||
* <div *ifRole="[Role.Store, Role.CallCenter]">Multiple roles</div>
|
||||
*
|
||||
* <!-- Show alternate content if user doesn't have role -->
|
||||
* <div *ifRole="Role.Store; else noAccess">Store content</div>
|
||||
* <ng-template #noAccess>No access</ng-template>
|
||||
*
|
||||
* <!-- Show content if user does NOT have role -->
|
||||
* <div *ifNotRole="Role.CallCenter">Non-CallCenter content</div>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[ifRole],[ifRoleElse],[ifNotRole],[ifNotRoleElse]',
|
||||
standalone: true,
|
||||
})
|
||||
export class IfRoleDirective {
|
||||
private readonly _templateRef = inject(TemplateRef<{ $implicit: Role | Role[] }>);
|
||||
private readonly _viewContainer = inject(ViewContainerRef);
|
||||
private readonly _roleService = inject(RoleService);
|
||||
|
||||
/**
|
||||
* Role(s) required to show the template
|
||||
*/
|
||||
readonly ifRole = input<Role | Role[]>();
|
||||
|
||||
/**
|
||||
* Alternative template to show if user doesn't have ifRole
|
||||
*/
|
||||
readonly ifRoleElse = input<TemplateRef<unknown>>();
|
||||
|
||||
/**
|
||||
* Role(s) that should NOT be present to show the template
|
||||
*/
|
||||
readonly ifNotRole = input<Role | Role[]>();
|
||||
|
||||
/**
|
||||
* Alternative template to show if user has ifNotRole
|
||||
*/
|
||||
readonly ifNotRoleElse = input<TemplateRef<unknown>>();
|
||||
|
||||
constructor() {
|
||||
// Use effect to reactively update the view when inputs change
|
||||
effect(() => {
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private get renderTemplateRef(): boolean {
|
||||
const role = this.ifRole();
|
||||
const notRole = this.ifNotRole();
|
||||
|
||||
if (role) {
|
||||
return this._roleService.hasRole(role);
|
||||
}
|
||||
if (notRole) {
|
||||
return !this._roleService.hasRole(notRole);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private get elseTemplateRef(): TemplateRef<unknown> | undefined {
|
||||
return this.ifRoleElse() || this.ifNotRoleElse();
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (this.renderTemplateRef) {
|
||||
this._viewContainer.clear();
|
||||
this._viewContainer.createEmbeddedView(this._templateRef, this.getContext());
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.elseTemplateRef) {
|
||||
this._viewContainer.clear();
|
||||
this._viewContainer.createEmbeddedView(this.elseTemplateRef, this.getContext());
|
||||
return;
|
||||
}
|
||||
|
||||
this._viewContainer.clear();
|
||||
}
|
||||
|
||||
private getContext(): { $implicit: Role | Role[] | undefined } {
|
||||
return {
|
||||
$implicit: this.ifRole() || this.ifNotRole(),
|
||||
};
|
||||
}
|
||||
}
|
||||
95
libs/core/auth/src/lib/role.service.spec.ts
Normal file
95
libs/core/auth/src/lib/role.service.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { RoleService } from './role.service';
|
||||
import { TOKEN_PROVIDER, TokenProvider } from './token-provider';
|
||||
import { Role } from './role';
|
||||
|
||||
describe('RoleService', () => {
|
||||
let service: RoleService;
|
||||
let tokenProvider: TokenProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenProvider = {
|
||||
getClaimByKey: vi.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [RoleService, { provide: TOKEN_PROVIDER, useValue: tokenProvider }],
|
||||
});
|
||||
|
||||
service = TestBed.inject(RoleService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('hasRole', () => {
|
||||
it('should return true when user has single required role', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store, Role.CallCenter]);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user does not have required role', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when user has all required roles (array)', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([
|
||||
Role.Store,
|
||||
Role.CallCenter,
|
||||
]);
|
||||
|
||||
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user is missing one of required roles', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
|
||||
|
||||
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user has no roles in token', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(null);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user has undefined roles', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(undefined);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockImplementation(() => {
|
||||
throw new Error('Token parsing error');
|
||||
});
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty role array', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store]);
|
||||
|
||||
expect(service.hasRole([])).toBe(true); // empty array means no requirements
|
||||
});
|
||||
|
||||
it('should handle single role as string (not array)', () => {
|
||||
// JWT might return a single string instead of array for single role
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(true);
|
||||
expect(service.hasRole(Role.CallCenter)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle single role string when checking multiple roles', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
|
||||
|
||||
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
libs/core/auth/src/lib/role.service.ts
Normal file
71
libs/core/auth/src/lib/role.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { TOKEN_PROVIDER } from './token-provider';
|
||||
import { Role } from './role';
|
||||
|
||||
/**
|
||||
* Service for role-based authorization checks
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Role } from '@isa/core/auth';
|
||||
*
|
||||
* const roleService = inject(RoleService);
|
||||
* if (roleService.hasRole(Role.Store)) {
|
||||
* // Show store features
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RoleService {
|
||||
private readonly _log = logger({ service: 'RoleService' });
|
||||
private readonly _tokenProvider = inject(TOKEN_PROVIDER);
|
||||
|
||||
/**
|
||||
* Check if the authenticated user has specific role(s)
|
||||
*
|
||||
* @param role Single role or array of roles to check
|
||||
* @returns true if user has all specified roles, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Role } from '@isa/core/auth';
|
||||
*
|
||||
* // Check single role
|
||||
* hasRole(Role.Store) // true if user has Store role
|
||||
*
|
||||
* // Check multiple roles (AND logic)
|
||||
* hasRole([Role.Store, Role.CallCenter]) // true only if user has BOTH roles
|
||||
* ```
|
||||
*/
|
||||
hasRole(role: Role | Role[]): boolean {
|
||||
const roles = coerceArray(role);
|
||||
|
||||
try {
|
||||
const userRoles = this._tokenProvider.getClaimByKey('role');
|
||||
|
||||
if (!userRoles) {
|
||||
this._log.debug('No roles found in token claims');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Coerce userRoles to array in case it's a single string
|
||||
const userRolesArray = coerceArray(userRoles);
|
||||
|
||||
const hasAllRoles = roles.every((r) => userRolesArray.includes(r));
|
||||
|
||||
this._log.debug(`Role check: ${roles.join(', ')} => ${hasAllRoles}`, () => ({
|
||||
requiredRoles: roles,
|
||||
userRoles: userRolesArray,
|
||||
}));
|
||||
|
||||
return hasAllRoles;
|
||||
} catch (error) {
|
||||
this._log.error('Error checking roles', error as Error, () => ({ requiredRoles: roles }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
libs/core/auth/src/lib/role.ts
Normal file
13
libs/core/auth/src/lib/role.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const Role = {
|
||||
/**
|
||||
* HSC
|
||||
*/
|
||||
CallCenter: 'CallCenter',
|
||||
|
||||
/**
|
||||
* Filiale
|
||||
*/
|
||||
Store: 'Store',
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
67
libs/core/auth/src/lib/token-provider.ts
Normal file
67
libs/core/auth/src/lib/token-provider.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { inject, InjectionToken } from '@angular/core';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
|
||||
/**
|
||||
* Token provider interface for role checking
|
||||
* The app can provide a custom implementation that returns user roles from the auth token
|
||||
*/
|
||||
export interface TokenProvider {
|
||||
/**
|
||||
* Get a claim value from the authentication token
|
||||
* @param key The claim key (e.g., 'role')
|
||||
* @returns The claim value or null if not found
|
||||
*/
|
||||
getClaimByKey(key: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT token to extract claims
|
||||
*/
|
||||
export function parseJwt(token: string | null): Record<string, unknown> | null {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const encoded = window.atob(base64);
|
||||
return JSON.parse(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for TokenProvider with default OAuthService implementation
|
||||
*
|
||||
* By default, this uses OAuthService to extract claims from the access token.
|
||||
* You can override this by providing your own implementation:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* providers: [
|
||||
* {
|
||||
* provide: TOKEN_PROVIDER,
|
||||
* useValue: {
|
||||
* getClaimByKey: (key) => customTokenService.getClaim(key)
|
||||
* }
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
|
||||
'TOKEN_PROVIDER',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const oAuthService = inject(OAuthService);
|
||||
return {
|
||||
getClaimByKey: (key: string) => {
|
||||
const claims = parseJwt(oAuthService.getAccessToken());
|
||||
return claims?.[key] ?? null;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
13
libs/core/auth/src/test-setup.ts
Normal file
13
libs/core/auth/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/core/auth/tsconfig.json
Normal file
30
libs/core/auth/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/core/auth/tsconfig.lib.json
Normal file
27
libs/core/auth/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/core/auth/tsconfig.spec.json
Normal file
29
libs/core/auth/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
33
libs/core/auth/vite.config.mts
Normal file
33
libs/core/auth/vite.config.mts
Normal file
@@ -0,0 +1,33 @@
|
||||
/// <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
|
||||
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
|
||||
defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/core/auth',
|
||||
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',
|
||||
['junit', { outputFile: '../../../testresults/junit-core-auth.xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/core/auth',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,4 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
import {
|
||||
ResponseArgsOfUserState,
|
||||
@@ -15,8 +14,8 @@ import {
|
||||
throwError,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import { USER_SUB } from '../tokens';
|
||||
import { Debounce, ValidateParam } from '@isa/common/decorators';
|
||||
import { AuthService } from '@core/auth';
|
||||
import z from 'zod';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
@@ -31,14 +30,12 @@ const DEFAULT_USER_STATE_RESPONSE: ResponseArgsOfUserState = {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStorageProvider implements StorageProvider {
|
||||
#logger = logger(() => ({
|
||||
context: 'UserStorageProvider',
|
||||
}));
|
||||
#logger = logger(() => ({ service: 'UserStorageProvider' }));
|
||||
#userStateService = inject(UserStateService);
|
||||
#userSub = toObservable(inject(USER_SUB));
|
||||
#authService = inject(AuthService);
|
||||
|
||||
#loadUserState = this.#userSub.pipe(
|
||||
filter((sub) => sub !== 'anonymous'),
|
||||
#loadUserState = this.#authService.authenticated$.pipe(
|
||||
filter((authenticated) => authenticated),
|
||||
switchMap(() =>
|
||||
this.#userStateService.UserStateGetUserState().pipe(
|
||||
catchError((error) => {
|
||||
@@ -50,10 +47,10 @@ export class UserStorageProvider implements StorageProvider {
|
||||
retry({
|
||||
count: 3,
|
||||
delay: (error, retryCount) => {
|
||||
this.#logger.warn(
|
||||
`Retrying to load user state, attempt #${retryCount}`,
|
||||
error,
|
||||
);
|
||||
this.#logger.warn('Retrying user state load', () => ({
|
||||
attempt: retryCount,
|
||||
error: error.message,
|
||||
}));
|
||||
return timer(1000 * retryCount); // Exponential backoff with timer
|
||||
},
|
||||
}),
|
||||
@@ -74,11 +71,15 @@ export class UserStorageProvider implements StorageProvider {
|
||||
#state: UserState = {};
|
||||
|
||||
async init() {
|
||||
this.#logger.info('Initializing UserStorageProvider');
|
||||
await firstValueFrom(this.#loadUserState);
|
||||
this.#logger.info('UserStorageProvider initialized');
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
this.#logger.info('Reloading user state');
|
||||
await firstValueFrom(this.#loadUserState);
|
||||
this.#logger.info('User state reloaded');
|
||||
}
|
||||
|
||||
#setCurrentState(state: UserState) {
|
||||
@@ -94,18 +95,24 @@ export class UserStorageProvider implements StorageProvider {
|
||||
|
||||
@Debounce({ wait: 1000 })
|
||||
private postNewState(): void {
|
||||
this.#logger.debug('Saving user state to server');
|
||||
const state = JSON.stringify(this.#state);
|
||||
firstValueFrom(
|
||||
this.#userStateService.UserStateSetUserState({
|
||||
content: state,
|
||||
}),
|
||||
).catch((error) => {
|
||||
this.#logger.error('Error saving user state:', error);
|
||||
});
|
||||
)
|
||||
.then(() => {
|
||||
this.#logger.debug('User state saved successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
this.#logger.error('Failed to save user state', error);
|
||||
});
|
||||
}
|
||||
|
||||
@ValidateParam(0, z.string().min(1))
|
||||
set(key: string, value: Record<string, unknown>): void {
|
||||
this.#logger.debug('Setting user state key', () => ({ key }));
|
||||
const current = this.#state;
|
||||
const content = structuredClone(current);
|
||||
content[key] = value;
|
||||
@@ -115,12 +122,13 @@ export class UserStorageProvider implements StorageProvider {
|
||||
|
||||
@ValidateParam(0, z.string().min(1))
|
||||
get(key: string): unknown {
|
||||
const data = structuredClone(this.#state[key]);
|
||||
return data;
|
||||
this.#logger.trace('Getting user state key', () => ({ key }));
|
||||
return structuredClone(this.#state[key]);
|
||||
}
|
||||
|
||||
@ValidateParam(0, z.string().min(1))
|
||||
clear(key: string): void {
|
||||
this.#logger.debug('Clearing user state key', () => ({ key }));
|
||||
const current = this.#state;
|
||||
if (key in current) {
|
||||
const content = structuredClone(current);
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { InjectionToken, signal, Signal } from '@angular/core';
|
||||
import { inject, InjectionToken, Signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
export const USER_SUB = new InjectionToken<Signal<string>>(
|
||||
'core.storage.user-sub',
|
||||
{
|
||||
factory: () => {
|
||||
return signal('anonymous');
|
||||
const auth = inject(AuthService);
|
||||
return toSignal(
|
||||
auth.authenticated$.pipe(
|
||||
// Map to user sub or 'anonymous' if not authenticated
|
||||
// This ensures that the signal updates when authentication state changes
|
||||
map(() => auth.getClaimByKey('sub') ?? 'anonymous'),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
52
libs/crm/data-access/src/lib/resources/payer.resource.ts
Normal file
52
libs/crm/data-access/src/lib/resources/payer.resource.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
43
libs/crm/data-access/src/lib/services/payer.service.ts
Normal file
43
libs/crm/data-access/src/lib/services/payer.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { LogisticianDTO } from '@generated/swagger/oms-api';
|
||||
import { Order } from '../models';
|
||||
import { DisplayOrder } from '../models';
|
||||
import { OrderCreationService } from '../services';
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,7 @@ export class OrderCreationFacade {
|
||||
* @returns Promise resolving to array of created orders
|
||||
* @throws {Error} If checkoutId is invalid or order creation fails
|
||||
*/
|
||||
async createOrdersFromCheckout(checkoutId: number): Promise<Order[]> {
|
||||
async createOrdersFromCheckout(checkoutId: number): Promise<DisplayOrder[]> {
|
||||
return this.#orderCreationService.createOrdersFromCheckout(checkoutId);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { getMainActions } from './get-main-actions.helper';
|
||||
import { DBHOrderItemListItem } from '../schemas';
|
||||
|
||||
describe('getMainActions', () => {
|
||||
it('should return actions from first item', () => {
|
||||
it('should return actions from first item when enabled is true', () => {
|
||||
// Arrange
|
||||
const items: DBHOrderItemListItem[] = [
|
||||
{
|
||||
actions: [
|
||||
{ key: 'action1', value: 'Action 1' },
|
||||
{ key: 'action2', value: 'Action 2' },
|
||||
{ key: 'action1', value: 'Action 1', enabled: true },
|
||||
{ key: 'action2', value: 'Action 2', enabled: true },
|
||||
],
|
||||
} as DBHOrderItemListItem,
|
||||
];
|
||||
@@ -19,12 +19,12 @@ describe('getMainActions', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([
|
||||
{ key: 'action1', value: 'Action 1' },
|
||||
{ key: 'action2', value: 'Action 2' },
|
||||
{ key: 'action1', value: 'Action 1', enabled: true },
|
||||
{ key: 'action2', value: 'Action 2', enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out actions with enabled boolean property', () => {
|
||||
it('should only include actions where enabled is true', () => {
|
||||
// Arrange
|
||||
const items: DBHOrderItemListItem[] = [
|
||||
{
|
||||
@@ -40,7 +40,9 @@ describe('getMainActions', () => {
|
||||
const result = getMainActions(items);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([{ key: 'action1', value: 'Action 1' }]);
|
||||
expect(result).toEqual([
|
||||
{ key: 'action2', value: 'Action 2', enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out actions containing FETCHED_PARTIAL when isPartial is true', () => {
|
||||
@@ -48,13 +50,24 @@ describe('getMainActions', () => {
|
||||
const items: DBHOrderItemListItem[] = [
|
||||
{
|
||||
actions: [
|
||||
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
|
||||
{
|
||||
key: 'action1',
|
||||
value: 'Action 1',
|
||||
command: 'DO_SOMETHING',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'action2',
|
||||
value: 'Action 2',
|
||||
command: 'FETCHED_PARTIAL_ACTION',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'action3',
|
||||
value: 'Action 3',
|
||||
command: 'DO_ANOTHER_THING',
|
||||
enabled: true,
|
||||
},
|
||||
{ key: 'action3', value: 'Action 3', command: 'DO_ANOTHER_THING' },
|
||||
],
|
||||
} as DBHOrderItemListItem,
|
||||
];
|
||||
@@ -64,8 +77,18 @@ describe('getMainActions', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([
|
||||
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
|
||||
{ key: 'action3', value: 'Action 3', command: 'DO_ANOTHER_THING' },
|
||||
{
|
||||
key: 'action1',
|
||||
value: 'Action 1',
|
||||
command: 'DO_SOMETHING',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'action3',
|
||||
value: 'Action 3',
|
||||
command: 'DO_ANOTHER_THING',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -74,11 +97,17 @@ describe('getMainActions', () => {
|
||||
const items: DBHOrderItemListItem[] = [
|
||||
{
|
||||
actions: [
|
||||
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
|
||||
{
|
||||
key: 'action1',
|
||||
value: 'Action 1',
|
||||
command: 'DO_SOMETHING',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'action2',
|
||||
value: 'Action 2',
|
||||
command: 'FETCHED_PARTIAL_ACTION',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
} as DBHOrderItemListItem,
|
||||
@@ -89,11 +118,17 @@ describe('getMainActions', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([
|
||||
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
|
||||
{
|
||||
key: 'action1',
|
||||
value: 'Action 1',
|
||||
command: 'DO_SOMETHING',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'action2',
|
||||
value: 'Action 2',
|
||||
command: 'FETCHED_PARTIAL_ACTION',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -149,7 +184,12 @@ describe('getMainActions', () => {
|
||||
const items: DBHOrderItemListItem[] = [
|
||||
{
|
||||
actions: [
|
||||
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
|
||||
{
|
||||
key: 'action1',
|
||||
value: 'Action 1',
|
||||
command: 'DO_SOMETHING',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'action2',
|
||||
value: 'Action 2',
|
||||
@@ -160,8 +200,10 @@ describe('getMainActions', () => {
|
||||
key: 'action3',
|
||||
value: 'Action 3',
|
||||
command: 'FETCHED_PARTIAL_ACTION',
|
||||
enabled: true,
|
||||
},
|
||||
{ key: 'action4', value: 'Action 4', enabled: false },
|
||||
{ key: 'action5', value: 'Action 5', command: 'DO_ANOTHER_THING' },
|
||||
],
|
||||
} as DBHOrderItemListItem,
|
||||
];
|
||||
@@ -171,7 +213,12 @@ describe('getMainActions', () => {
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([
|
||||
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
|
||||
{
|
||||
key: 'action1',
|
||||
value: 'Action 1',
|
||||
command: 'DO_SOMETHING',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export const getMainActions = (
|
||||
const firstItem = items?.find((_) => true);
|
||||
return (
|
||||
firstItem?.actions
|
||||
?.filter((action) => typeof action?.enabled !== 'boolean')
|
||||
?.filter((action) => action?.enabled === true)
|
||||
?.filter((action) =>
|
||||
isPartial ? !action?.command?.includes('FETCHED_PARTIAL') : true,
|
||||
) ?? []
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './return-process';
|
||||
export * from './reward';
|
||||
export * from './get-main-actions.helper';
|
||||
export * from './order';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Extracts and parses the reward points (Lesepunkte) value from an order item's features.
|
||||
*
|
||||
* @param orderItem - An object containing a features property with key-value pairs
|
||||
* @returns The parsed numeric value of reward points, or undefined if not present
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const orderItem = { features: { praemie: '12.345' } };
|
||||
* const points = getOrderItemRewardFeature(orderItem); // returns 12345
|
||||
* ```
|
||||
*/
|
||||
export function getOrderItemRewardFeatureHelper(
|
||||
orderItem: { features?: { [key: string]: string } } | undefined,
|
||||
): undefined | number {
|
||||
if (!orderItem || !orderItem.features) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Return 12.345 as string needs to be converted to number. Remove . to avoid issues with different locales.
|
||||
const rewardFeature = orderItem.features['praemie'];
|
||||
|
||||
return rewardFeature
|
||||
? Number(rewardFeature.replace('.', '').replace(',', '.'))
|
||||
: undefined;
|
||||
}
|
||||
1
libs/oms/data-access/src/lib/helpers/order/index.ts
Normal file
1
libs/oms/data-access/src/lib/helpers/order/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getOrderItemRewardFeatureHelper as getOrderItemRewardFeature } from './get-order-item-reward-feature.helper';
|
||||
@@ -3,6 +3,22 @@ import { ProcessingStatusState } from '../../models';
|
||||
import { getProcessingStatusState } from './get-processing-status-state.helper';
|
||||
|
||||
describe('getProcessingStatusState', () => {
|
||||
describe('Ordered status', () => {
|
||||
it('should return Ordered when all items are Bestellt', () => {
|
||||
// Arrange
|
||||
const statuses = [
|
||||
OrderItemProcessingStatusValue.Bestellt,
|
||||
OrderItemProcessingStatusValue.Bestellt,
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getProcessingStatusState(statuses);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(ProcessingStatusState.Ordered);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancelled status', () => {
|
||||
it('should return Cancelled when all items are cancelled', () => {
|
||||
// Arrange
|
||||
@@ -117,5 +133,19 @@ describe('getProcessingStatusState', () => {
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when items have mix of ordered and cancelled', () => {
|
||||
// Arrange
|
||||
const statuses = [
|
||||
OrderItemProcessingStatusValue.Bestellt,
|
||||
OrderItemProcessingStatusValue.Storniert, // 1024
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getProcessingStatusState(statuses);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,14 @@ export const getProcessingStatusState = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if all statuses are ordered
|
||||
const allOrdered = statuses.every(
|
||||
(status) => status === OrderItemProcessingStatusValue.Bestellt,
|
||||
);
|
||||
if (allOrdered) {
|
||||
return ProcessingStatusState.Ordered;
|
||||
}
|
||||
|
||||
// Check if all statuses are cancelled
|
||||
const allCancelled = statuses.every(
|
||||
(status) =>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
|
||||
|
||||
/**
|
||||
* Order model representing a completed checkout order.
|
||||
* This is an alias to the OMS API's DisplayOrderDTO.
|
||||
*
|
||||
* @remarks
|
||||
* DisplayOrderDTO contains:
|
||||
* - Order metadata (orderNumber, orderDate, orderType, orderValue)
|
||||
* - Customer information (buyer, payer, shippingAddress)
|
||||
* - Order items and their details
|
||||
* - Payment information
|
||||
* - Branch and delivery information
|
||||
*/
|
||||
export type Order = DisplayOrderDTO;
|
||||
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
|
||||
|
||||
/**
|
||||
* Order model representing a completed checkout order.
|
||||
* This is an alias to the OMS API's DisplayOrderDTO.
|
||||
*
|
||||
* @remarks
|
||||
* DisplayOrderDTO contains:
|
||||
* - Order metadata (orderNumber, orderDate, orderType, orderValue)
|
||||
* - Customer information (buyer, payer, shippingAddress)
|
||||
* - Order items and their details
|
||||
* - Payment information
|
||||
* - Branch and delivery information
|
||||
*/
|
||||
export type DisplayOrder = DisplayOrderDTO;
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from './address-type';
|
||||
export * from './buyer';
|
||||
export * from './can-return';
|
||||
export * from './display-order';
|
||||
export * from './eligible-for-return';
|
||||
export * from './logistician';
|
||||
export * from './order';
|
||||
export * from './processing-status-state';
|
||||
export * from './quantity';
|
||||
export * from './receipt-item-list-item';
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Processing status state types for order items
|
||||
*/
|
||||
export const ProcessingStatusState = {
|
||||
/** Item is still ordered and pending processing */
|
||||
Ordered: 'ordered',
|
||||
/** Item was cancelled by customer, merchant, or supplier */
|
||||
Cancelled: 'cancelled',
|
||||
/** Item was not found / not available */
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { EntitySchema, DateRangeSchema } from '@isa/common/data-access';
|
||||
import {
|
||||
EntitySchema,
|
||||
DateRangeSchema,
|
||||
KeyValueOfStringAndStringSchema,
|
||||
} from '@isa/common/data-access';
|
||||
import { z } from 'zod';
|
||||
import { OrderItemProcessingStatusValueSchema } from './order-item-processing-status-value.schema';
|
||||
|
||||
// Forward declaration for circular reference
|
||||
export const DisplayOrderItemSubsetSchema = z
|
||||
.object({
|
||||
actions: z
|
||||
.array(KeyValueOfStringAndStringSchema)
|
||||
.describe('Possible actions')
|
||||
.optional(),
|
||||
compartmentCode: z.string().describe('Compartment code').optional(),
|
||||
compartmentInfo: z.string().describe('Compartment information').optional(),
|
||||
compartmentStart: z.string().describe('Compartment start').optional(),
|
||||
|
||||
@@ -69,5 +69,3 @@ export const DisplayOrderSchema = z
|
||||
TermsOfDeliverySchema.describe('Terms of delivery').optional(),
|
||||
})
|
||||
.extend(EntitySchema.shape);
|
||||
|
||||
export type DisplayOrder = z.infer<typeof DisplayOrderSchema>;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
* Service for fetching open reward distribution tasks (Prämienausgabe) from the OMS API.
|
||||
*
|
||||
* Provides cached access to unfinished reward orders with automatic request deduplication.
|
||||
* These are reward orders in processing statuses 16 (InPreparation) and 128 (ReadyForPickup)
|
||||
* These are reward orders in processing statuses 16 (Bestellt) and 8192 (Nachbestellt)
|
||||
* that need to be completed.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -24,7 +24,7 @@ export class OpenRewardTasksService {
|
||||
* Fetches open reward distribution tasks (unfinished Prämienausgabe orders).
|
||||
*
|
||||
* Returns reward orders that are:
|
||||
* - In status 16 (InPreparation) or 128 (ReadyForPickup)
|
||||
* - In status 16 (Bestellt) or 8192 (Nachbestellt)
|
||||
* - Flagged as reward items (praemie: "1-")
|
||||
*
|
||||
* Results are cached for 1 minute to balance freshness with performance.
|
||||
@@ -42,7 +42,7 @@ export class OpenRewardTasksService {
|
||||
const payload: QueryTokenDTO = {
|
||||
input: {},
|
||||
filter: {
|
||||
orderitemprocessingstatus: '16', // InPreparation(16) and ReadyForPickup(128)
|
||||
orderitemprocessingstatus: '16;8192', // Bestellt(16) and Nachbestellt(8192)
|
||||
praemie: '1-', // Reward items only
|
||||
supplier_id: '16',
|
||||
},
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
OrderCheckoutService,
|
||||
LogisticianService,
|
||||
LogisticianDTO,
|
||||
} from '@generated/swagger/oms-api';
|
||||
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Order } from '../models';
|
||||
|
||||
/**
|
||||
* Service for creating orders from checkout.
|
||||
*
|
||||
* @remarks
|
||||
* This service handles order creation operations that are part of the OMS domain.
|
||||
* It provides methods to:
|
||||
* - Create orders from a completed checkout
|
||||
* - Retrieve logistician information
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrderCreationService {
|
||||
#logger = logger(() => ({ service: 'OrderCreationService' }));
|
||||
readonly #orderCheckoutService = inject(OrderCheckoutService);
|
||||
readonly #logisticianService = inject(LogisticianService);
|
||||
|
||||
/**
|
||||
* Creates orders from a checkout.
|
||||
*
|
||||
* @param checkoutId - The ID of the checkout to create orders from
|
||||
* @returns Promise resolving to array of created orders
|
||||
* @throws {Error} If checkoutId is invalid or order creation fails
|
||||
*/
|
||||
async createOrdersFromCheckout(checkoutId: number): Promise<Order[]> {
|
||||
if (!checkoutId) {
|
||||
throw new Error(`Invalid checkoutId: ${checkoutId}`);
|
||||
}
|
||||
|
||||
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
|
||||
checkoutId,
|
||||
});
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
const error = new ResponseArgsError(res);
|
||||
this.#logger.error('Failed to create orders', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.result as Order[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves logistician information.
|
||||
*
|
||||
* @param logisticianNumber - The logistician number to retrieve (defaults to '2470')
|
||||
* @param abortSignal - Optional signal to abort the operation
|
||||
* @returns Promise resolving to logistician data
|
||||
* @throws {Error} If logistician is not found or request fails
|
||||
*/
|
||||
async getLogistician(
|
||||
logisticianNumber = '2470',
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<LogisticianDTO> {
|
||||
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
|
||||
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
const error = new ResponseArgsError(res);
|
||||
this.#logger.error('Failed to get logistician', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const logistician = res.result?.find(
|
||||
(l) => l.logisticianNumber === logisticianNumber,
|
||||
);
|
||||
|
||||
if (!logistician) {
|
||||
throw new Error(`Logistician ${logisticianNumber} not found`);
|
||||
}
|
||||
|
||||
return logistician;
|
||||
}
|
||||
}
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
OrderCheckoutService,
|
||||
LogisticianService,
|
||||
LogisticianDTO,
|
||||
} from '@generated/swagger/oms-api';
|
||||
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DisplayOrder } from '../models';
|
||||
|
||||
/**
|
||||
* Service for creating orders from checkout.
|
||||
*
|
||||
* @remarks
|
||||
* This service handles order creation operations that are part of the OMS domain.
|
||||
* It provides methods to:
|
||||
* - Create orders from a completed checkout
|
||||
* - Retrieve logistician information
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrderCreationService {
|
||||
#logger = logger(() => ({ service: 'OrderCreationService' }));
|
||||
readonly #orderCheckoutService = inject(OrderCheckoutService);
|
||||
readonly #logisticianService = inject(LogisticianService);
|
||||
|
||||
/**
|
||||
* Creates orders from a checkout.
|
||||
*
|
||||
* @param checkoutId - The ID of the checkout to create orders from
|
||||
* @returns Promise resolving to array of created orders
|
||||
* @throws {Error} If checkoutId is invalid or order creation fails
|
||||
*/
|
||||
async createOrdersFromCheckout(checkoutId: number): Promise<DisplayOrder[]> {
|
||||
if (!checkoutId) {
|
||||
throw new Error(`Invalid checkoutId: ${checkoutId}`);
|
||||
}
|
||||
|
||||
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
|
||||
checkoutId,
|
||||
});
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
const error = new ResponseArgsError(res);
|
||||
this.#logger.error('Failed to create orders', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.result as DisplayOrder[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves logistician information.
|
||||
*
|
||||
* @param logisticianNumber - The logistician number to retrieve (defaults to '2470')
|
||||
* @param abortSignal - Optional signal to abort the operation
|
||||
* @returns Promise resolving to logistician data
|
||||
* @throws {Error} If logistician is not found or request fails
|
||||
*/
|
||||
async getLogistician(
|
||||
logisticianNumber = '2470',
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<LogisticianDTO> {
|
||||
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
|
||||
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
const error = new ResponseArgsError(res);
|
||||
this.#logger.error('Failed to get logistician', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const logistician = res.result?.find(
|
||||
(l) => l.logisticianNumber === logisticianNumber,
|
||||
);
|
||||
|
||||
if (!logistician) {
|
||||
throw new Error(`Logistician ${logisticianNumber} not found`);
|
||||
}
|
||||
|
||||
return logistician;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
></ng-icon>
|
||||
</span>
|
||||
<p
|
||||
class="isa-text-body-1-bold text-isa-neutral-900"
|
||||
class="isa-text-body-1-bold text-isa-neutral-900 whitespace-pre-line text-center"
|
||||
data-what="error-message"
|
||||
>
|
||||
{{ data.errorMessage }}
|
||||
{{ data.errorMessage ?? 'Unbekannter Fehler' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isaActionClose } from '@isa/icons';
|
||||
*/
|
||||
export interface FeedbackErrorDialogData {
|
||||
/** The Error message text to display in the dialog */
|
||||
errorMessage: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
76779
package-lock.json
generated
76779
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@
|
||||
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
|
||||
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
|
||||
"@isa/common/print": ["libs/common/print/src/index.ts"],
|
||||
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
|
||||
"@isa/core/config": ["libs/core/config/src/index.ts"],
|
||||
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
|
||||
"@isa/core/navigation": ["libs/core/navigation/src/index.ts"],
|
||||
|
||||
Reference in New Issue
Block a user