mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 14:32:10 +01:00
Merged PR 2031: feat(crm): add customer bon redemption feature
feat(crm): add customer bon redemption feature - New library @isa/crm/feature/customer-bon-redemption - Implement bon validation and redemption flow - Add SignalStore for state management - Add resource pattern for reactive data loading - Add facade for business logic abstraction - Add Zod schemas for runtime validation - Integrate with loyalty card API endpoints - Add accessibility and E2E test attributes - Remove mock provider (use real facade) - Exclude generated swagger files from linting Components: - BonInputFieldComponent - input with validation - BonDetailsDisplayComponent - shows validated bon - BonRedemptionButtonComponent - redemption action Data Access: - CustomerBonRedemptionFacade - business logic - CustomerBonCheckResource - reactive validation - BonRedemptionStore - component state - CrmSearchService - API integration (checkBon, addBon) Issue: 5314 Related work items: #5314
This commit is contained in:
committed by
Nino Righi
parent
8c0de558a4
commit
fc6d29d62f
@@ -11,10 +11,17 @@
|
||||
class="mt-4"
|
||||
/>
|
||||
@let cardCode = firstActiveCardCode();
|
||||
|
||||
@if (cardCode) {
|
||||
<crm-customer-bon-redemption
|
||||
[cardCode]="cardCode"
|
||||
class="mt-4"
|
||||
(redeemed)="reloadCardTransactions()"
|
||||
/>
|
||||
<crm-customer-booking [cardCode]="cardCode" class="mt-4" />
|
||||
<crm-customer-card-transactions [cardCode]="cardCode" class="mt-8" />
|
||||
}
|
||||
<crm-customer-card-transactions [cardCode]="cardCode" class="mt-8" />
|
||||
|
||||
<utils-scroll-top-button
|
||||
[target]="hostElement"
|
||||
class="flex flex-col justify-self-end fixed bottom-6 right-6"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { CustomerSearchStore } from '../store';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
@@ -14,8 +15,12 @@ import { CustomerMenuComponent } from '../../components/customer-menu';
|
||||
import { CustomerLoyaltyCardsComponent } from '@isa/crm/feature/customer-loyalty-cards';
|
||||
import { CrmFeatureCustomerCardTransactionsComponent } from '@isa/crm/feature/customer-card-transactions';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CustomerBonusCardsResource } from '@isa/crm/data-access';
|
||||
import {
|
||||
CustomerBonusCardsResource,
|
||||
CustomerCardTransactionsResource,
|
||||
} from '@isa/crm/data-access';
|
||||
import { CrmFeatureCustomerBookingComponent } from '@isa/crm/feature/customer-booking';
|
||||
import { CrmFeatureCustomerBonRedemptionComponent } from '@isa/crm/feature/customer-bon-redemption';
|
||||
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
|
||||
|
||||
@Component({
|
||||
@@ -30,15 +35,18 @@ import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
|
||||
CustomerLoyaltyCardsComponent,
|
||||
CrmFeatureCustomerCardTransactionsComponent,
|
||||
CrmFeatureCustomerBookingComponent,
|
||||
CrmFeatureCustomerBonRedemptionComponent,
|
||||
ScrollTopButtonComponent,
|
||||
],
|
||||
providers: [CustomerBonusCardsResource],
|
||||
providers: [CustomerBonusCardsResource, CustomerCardTransactionsResource],
|
||||
})
|
||||
export class KundenkarteMainViewComponent {
|
||||
export class KundenkarteMainViewComponent implements OnDestroy {
|
||||
#reloadTimeoutId?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _activatedRoute = inject(ActivatedRoute);
|
||||
private _bonusCardsResource = inject(CustomerBonusCardsResource);
|
||||
|
||||
#cardTransactionsResource = inject(CustomerCardTransactionsResource);
|
||||
elementRef = inject(ElementRef);
|
||||
|
||||
get hostElement() {
|
||||
@@ -74,4 +82,16 @@ export class KundenkarteMainViewComponent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reloadCardTransactions() {
|
||||
this.#reloadTimeoutId = setTimeout(() => {
|
||||
this.#cardTransactionsResource.resource.reload();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.#reloadTimeoutId) {
|
||||
clearTimeout(this.#reloadTimeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = [
|
||||
'**/dist',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*',
|
||||
'**/generated/**',
|
||||
],
|
||||
},
|
||||
// {
|
||||
|
||||
@@ -106,6 +106,8 @@ export { KeyValueDTOOfStringAndInteger } from './models/key-value-dtoof-string-a
|
||||
export { ResponseArgsOfKeyValueDTOOfStringAndString } from './models/response-args-of-key-value-dtoof-string-and-string';
|
||||
export { ResponseArgsOfLoyaltyBookingInfoDTO } from './models/response-args-of-loyalty-booking-info-dto';
|
||||
export { LoyaltyBookingValues } from './models/loyalty-booking-values';
|
||||
export { ResponseArgsOfLoyaltyBonResponse } from './models/response-args-of-loyalty-bon-response';
|
||||
export { LoyaltyBonResponse } from './models/loyalty-bon-response';
|
||||
export { LoyaltyBonValues } from './models/loyalty-bon-values';
|
||||
export { ResponseArgsOfPayerDTO } from './models/response-args-of-payer-dto';
|
||||
export { ResponseArgsOfShippingAddressDTO } from './models/response-args-of-shipping-address-dto';
|
||||
|
||||
13
generated/swagger/crm-api/src/models/loyalty-bon-response.ts
Normal file
13
generated/swagger/crm-api/src/models/loyalty-bon-response.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/* tslint:disable */
|
||||
export interface LoyaltyBonResponse {
|
||||
|
||||
/**
|
||||
* Bon Datum
|
||||
*/
|
||||
date?: string;
|
||||
|
||||
/**
|
||||
* Summe
|
||||
*/
|
||||
total?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* tslint:disable */
|
||||
import { ResponseArgs } from './response-args';
|
||||
import { LoyaltyBonResponse } from './loyalty-bon-response';
|
||||
export interface ResponseArgsOfLoyaltyBonResponse extends ResponseArgs{
|
||||
result?: LoyaltyBonResponse;
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import { ResponseArgsOfLoyaltyBookingInfoDTO } from '../models/response-args-of-
|
||||
import { LoyaltyBookingValues } from '../models/loyalty-booking-values';
|
||||
import { ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger } from '../models/response-args-of-ienumerable-of-key-value-dtoof-string-and-integer';
|
||||
import { ResponseArgsOfKeyValueDTOOfStringAndString } from '../models/response-args-of-key-value-dtoof-string-and-string';
|
||||
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
|
||||
import { ResponseArgsOfLoyaltyBonResponse } from '../models/response-args-of-loyalty-bon-response';
|
||||
import { LoyaltyBonValues } from '../models/loyalty-bon-values';
|
||||
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
|
||||
import { ResponseArgsOfNullableBoolean } from '../models/response-args-of-nullable-boolean';
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -206,7 +207,7 @@ class LoyaltyCardService extends __BaseService {
|
||||
*
|
||||
* - `locale`:
|
||||
*/
|
||||
LoyaltyCardLoyaltyBonCheckResponse(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<__StrictHttpResponse<ResponseArgsOfBoolean>> {
|
||||
LoyaltyCardLoyaltyBonCheckResponse(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<__StrictHttpResponse<ResponseArgsOfLoyaltyBonResponse>> {
|
||||
let __params = this.newParams();
|
||||
let __headers = new HttpHeaders();
|
||||
let __body: any = null;
|
||||
@@ -226,7 +227,7 @@ class LoyaltyCardService extends __BaseService {
|
||||
return this.http.request<any>(req).pipe(
|
||||
__filter(_r => _r instanceof HttpResponse),
|
||||
__map((_r) => {
|
||||
return _r as __StrictHttpResponse<ResponseArgsOfBoolean>;
|
||||
return _r as __StrictHttpResponse<ResponseArgsOfLoyaltyBonResponse>;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -240,9 +241,9 @@ class LoyaltyCardService extends __BaseService {
|
||||
*
|
||||
* - `locale`:
|
||||
*/
|
||||
LoyaltyCardLoyaltyBonCheck(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<ResponseArgsOfBoolean> {
|
||||
LoyaltyCardLoyaltyBonCheck(params: LoyaltyCardService.LoyaltyCardLoyaltyBonCheckParams): __Observable<ResponseArgsOfLoyaltyBonResponse> {
|
||||
return this.LoyaltyCardLoyaltyBonCheckResponse(params).pipe(
|
||||
__map(_r => _r.body as ResponseArgsOfBoolean)
|
||||
__map(_r => _r.body as ResponseArgsOfLoyaltyBonResponse)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './lib/resources';
|
||||
export * from './lib/helpers';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/services';
|
||||
export * from './lib/stores';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ResponseArgs } from '@isa/common/data-access';
|
||||
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
|
||||
|
||||
/**
|
||||
* Mock implementation of CustomerBonRedemptionFacade for UI testing without backend.
|
||||
*
|
||||
* Test Bon numbers:
|
||||
* - "123456789" - Valid Bon with data
|
||||
* - "987654321" - Valid Bon with different data
|
||||
* - "111111111" - Valid Bon with high total
|
||||
* - Any other number - Returns "Keine verpunktung möglich"
|
||||
*
|
||||
* @example
|
||||
* // In component for testing, provide the mock:
|
||||
* providers: [
|
||||
* { provide: CustomerBonRedemptionFacade, useClass: CustomerBonRedemptionFacadeMock }
|
||||
* ]
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerBonRedemptionFacadeMock {
|
||||
/**
|
||||
* Mock Bon data for testing
|
||||
*/
|
||||
private readonly mockBonData: Record<string, LoyaltyBonResponse> = {
|
||||
'123456789': {
|
||||
date: '23.05.2025',
|
||||
total: 76.12,
|
||||
},
|
||||
'987654321': {
|
||||
date: '15.04.2025',
|
||||
total: 42.5,
|
||||
},
|
||||
'111111111': {
|
||||
date: '01.01.2025',
|
||||
total: 150.0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulate API delay (300ms)
|
||||
*/
|
||||
private async simulateDelay(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock check Bon implementation
|
||||
*/
|
||||
async checkBon(params: {
|
||||
cardCode: string;
|
||||
bonNr: string;
|
||||
storeId?: string;
|
||||
}): Promise<ResponseArgs<LoyaltyBonResponse>> {
|
||||
await this.simulateDelay();
|
||||
|
||||
const bonData = this.mockBonData[params.bonNr];
|
||||
|
||||
if (bonData) {
|
||||
return {
|
||||
result: bonData,
|
||||
error: false,
|
||||
message: 'Bon gefunden',
|
||||
};
|
||||
}
|
||||
|
||||
// Return empty result for unknown Bon numbers (triggers "Keine verpunktung möglich")
|
||||
return {
|
||||
result: undefined,
|
||||
error: false,
|
||||
message: 'Keine verpunktung möglich',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock add Bon implementation
|
||||
*/
|
||||
async addBon(_params: {
|
||||
cardCode: string;
|
||||
bonNr: string;
|
||||
storeId?: string;
|
||||
}): Promise<boolean> {
|
||||
await this.simulateDelay();
|
||||
|
||||
// Always succeed for mock
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { CrmSearchService } from '../services/crm-search.service';
|
||||
import { CheckBonInput, AddBonInput } from '../schemas';
|
||||
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
|
||||
import { ResponseArgs } from '@isa/common/data-access';
|
||||
|
||||
/**
|
||||
* Facade for customer Bon redemption operations.
|
||||
*
|
||||
* Provides a simplified API for validating and redeeming customer receipts (Bons)
|
||||
* for loyalty points.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CustomerBonRedemptionFacade {
|
||||
#crmSearchService = inject(CrmSearchService);
|
||||
|
||||
/**
|
||||
* Check/validate a Bon number
|
||||
*
|
||||
* @param params - Bon validation parameters
|
||||
* @param abortSignal - Optional abort signal for cancellation
|
||||
* @returns Response with Bon details (date, total)
|
||||
*/
|
||||
async checkBon(
|
||||
params: CheckBonInput,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ResponseArgs<LoyaltyBonResponse>> {
|
||||
// Fetch store ID if not provided
|
||||
if (!params.storeId) {
|
||||
const store =
|
||||
await this.#crmSearchService.fetchCurrentBookingPartnerStore();
|
||||
params = { ...params, storeId: store?.key };
|
||||
}
|
||||
|
||||
return this.#crmSearchService.checkBon(params, abortSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem/add a Bon for customer points
|
||||
*
|
||||
* @param params - Bon redemption parameters
|
||||
* @returns True if redemption successful, false otherwise
|
||||
*/
|
||||
async addBon(params: AddBonInput): Promise<boolean> {
|
||||
// Fetch store ID if not provided
|
||||
if (!params.storeId) {
|
||||
const store =
|
||||
await this.#crmSearchService.fetchCurrentBookingPartnerStore();
|
||||
params = { ...params, storeId: store?.key };
|
||||
}
|
||||
|
||||
return this.#crmSearchService.addBon(params);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './customer-cards.facade';
|
||||
export * from './customer.facade';
|
||||
export * from './customer-card-booking.facade';
|
||||
export * from './customer-bon-redemption.facade';
|
||||
export * from './customer-bon-redemption.facade.mock';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable, inject, resource, signal, computed } from '@angular/core';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
|
||||
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
|
||||
|
||||
/**
|
||||
* Resource for checking/validating Bon numbers.
|
||||
*
|
||||
* Provides reactive loading of Bon details for validation.
|
||||
* Parameters can be updated via `params()` method to trigger validation.
|
||||
*
|
||||
* **Note:** Provide at component level, not root.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerBonCheckResource {
|
||||
readonly #bonFacade = inject(CustomerBonRedemptionFacade);
|
||||
readonly #logger = logger(() => ({ context: 'CustomerBonCheckResource' }));
|
||||
|
||||
readonly #cardCode = signal<string | undefined>(undefined);
|
||||
readonly #bonNr = signal<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Resource that validates Bon based on current parameters.
|
||||
*/
|
||||
readonly resource = resource({
|
||||
params: computed(() => ({
|
||||
cardCode: this.#cardCode(),
|
||||
bonNr: this.#bonNr(),
|
||||
})),
|
||||
loader: async ({
|
||||
params,
|
||||
abortSignal,
|
||||
}): Promise<LoyaltyBonResponse | undefined> => {
|
||||
const { cardCode, bonNr } = params;
|
||||
|
||||
if (!cardCode || !bonNr) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.#logger.debug('Checking Bon', () => ({ cardCode, bonNr }));
|
||||
|
||||
const response = await this.#bonFacade.checkBon(
|
||||
{ cardCode, bonNr },
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.#logger.debug('Bon checked', () => ({
|
||||
bonNr,
|
||||
found: !!response?.result,
|
||||
}));
|
||||
|
||||
return response?.result;
|
||||
},
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* Update parameters to trigger Bon validation.
|
||||
*/
|
||||
params(params: { cardCode: string; bonNr: string }): void {
|
||||
this.#cardCode.set(params.cardCode);
|
||||
this.#bonNr.set(params.bonNr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the resource state.
|
||||
*/
|
||||
reset(): void {
|
||||
this.#cardCode.set(undefined);
|
||||
this.#bonNr.set(undefined);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { LoyaltyBookingInfoDTO } from '@generated/swagger/crm-api';
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@Injectable()
|
||||
export class CustomerCardTransactionsResource {
|
||||
readonly #crmSearchService = inject(CrmSearchService);
|
||||
readonly #logger = logger(() => ({
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './customer-shipping-addresses.resource';
|
||||
export * from './customer.resource';
|
||||
export * from './payer.resource';
|
||||
export * from './customer-booking-reasons.resource';
|
||||
export * from './customer-bon-check.resource';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for checking/validating a Bon
|
||||
*/
|
||||
export const CheckBonSchema = z.object({
|
||||
cardCode: z.string().min(1, 'Karten-Code ist erforderlich'),
|
||||
bonNr: z.string().min(1, 'Bon-Nummer ist erforderlich'),
|
||||
storeId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CheckBon = z.infer<typeof CheckBonSchema>;
|
||||
export type CheckBonInput = z.input<typeof CheckBonSchema>;
|
||||
|
||||
/**
|
||||
* Schema for redeeming a Bon
|
||||
*/
|
||||
export const AddBonSchema = z.object({
|
||||
cardCode: z.string().min(1, 'Karten-Code ist erforderlich'),
|
||||
bonNr: z.string().min(1, 'Bon-Nummer ist erforderlich'),
|
||||
storeId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AddBon = z.infer<typeof AddBonSchema>;
|
||||
export type AddBonInput = z.input<typeof AddBonSchema>;
|
||||
@@ -18,3 +18,4 @@ export * from './payment-settings.schema';
|
||||
export * from './shipping-address.schema';
|
||||
export * from './user.schema';
|
||||
export * from './add-booking.schema';
|
||||
export * from './bon-redemption.schema';
|
||||
|
||||
@@ -5,11 +5,15 @@ import {
|
||||
LoyaltyBookingInfoDTO,
|
||||
KeyValueDTOOfStringAndString,
|
||||
KeyValueDTOOfStringAndInteger,
|
||||
LoyaltyBonResponse,
|
||||
} from '@generated/swagger/crm-api';
|
||||
import {
|
||||
AddBooking,
|
||||
AddBookingInput,
|
||||
AddBookingSchema,
|
||||
CheckBonInput,
|
||||
CheckBonSchema,
|
||||
AddBonInput,
|
||||
AddBonSchema,
|
||||
Customer,
|
||||
FetchCustomerCardsInput,
|
||||
FetchCustomerCardsSchema,
|
||||
@@ -22,6 +26,7 @@ import {
|
||||
ResponseArgsError,
|
||||
takeUntilAborted,
|
||||
} from '@isa/common/data-access';
|
||||
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { BonusCardInfo } from '../models';
|
||||
import { logger } from '@isa/core/logging';
|
||||
@@ -135,6 +140,7 @@ export class CrmSearchService {
|
||||
}
|
||||
}
|
||||
|
||||
@Cache({ ttl: CacheTimeToLive.oneHour })
|
||||
async fetchCurrentBookingPartnerStore(
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<KeyValueDTOOfStringAndString | undefined> {
|
||||
@@ -183,4 +189,66 @@ export class CrmSearchService {
|
||||
|
||||
return res?.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check/validate a Bon number
|
||||
*/
|
||||
async checkBon(
|
||||
params: CheckBonInput,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ResponseArgs<LoyaltyBonResponse>> {
|
||||
this.#logger.info('Checking Bon from API');
|
||||
const { cardCode, bonNr, storeId } = CheckBonSchema.parse(params);
|
||||
|
||||
let req$ = this.#loyaltyCardService
|
||||
.LoyaltyCardLoyaltyBonCheck({
|
||||
cardCode,
|
||||
payload: { bonNr, storeId },
|
||||
})
|
||||
.pipe(catchResponseArgsErrorPipe());
|
||||
|
||||
if (abortSignal) {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await firstValueFrom(req$);
|
||||
this.#logger.debug('Successfully checked Bon');
|
||||
|
||||
if (res.error) {
|
||||
const err = new ResponseArgsError(res);
|
||||
this.#logger.error('Bon check failed', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return res as ResponseArgs<LoyaltyBonResponse>;
|
||||
} catch (error) {
|
||||
this.#logger.error('Error checking Bon', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem/add a Bon for customer points
|
||||
*/
|
||||
async addBon(params: AddBonInput): Promise<boolean> {
|
||||
this.#logger.info('Redeeming Bon from API');
|
||||
const { cardCode, bonNr, storeId } = AddBonSchema.parse(params);
|
||||
|
||||
const req$ = this.#loyaltyCardService.LoyaltyCardLoyaltyBonAdd({
|
||||
cardCode,
|
||||
payload: { bonNr, storeId },
|
||||
});
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
const err = new ResponseArgsError(res);
|
||||
this.#logger.error('Bon redemption failed', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.#logger.debug('Successfully redeemed Bon');
|
||||
return res?.result ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
169
libs/crm/data-access/src/lib/stores/bon-redemption.store.ts
Normal file
169
libs/crm/data-access/src/lib/stores/bon-redemption.store.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { computed } from '@angular/core';
|
||||
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
|
||||
import { patchState } from '@ngrx/signals';
|
||||
|
||||
/**
|
||||
* Validated Bon data structure
|
||||
*
|
||||
* Maps to LoyaltyBonResponse from @generated/swagger/crm-api
|
||||
*/
|
||||
export interface ValidatedBon {
|
||||
bonNumber: string;
|
||||
date: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Bon redemption feature
|
||||
*/
|
||||
interface BonRedemptionState {
|
||||
/**
|
||||
* Current Bon number input by user
|
||||
*/
|
||||
bonNumber: string;
|
||||
|
||||
/**
|
||||
* Whether Bon validation is in progress
|
||||
*/
|
||||
isValidating: boolean;
|
||||
|
||||
/**
|
||||
* Whether Bon redemption is in progress
|
||||
*/
|
||||
isRedeeming: boolean;
|
||||
|
||||
/**
|
||||
* Tracks if validation was attempted (button clicked)
|
||||
*/
|
||||
validationAttempted: boolean;
|
||||
|
||||
/**
|
||||
* Validated Bon data after successful validation
|
||||
*/
|
||||
validatedBon: ValidatedBon | undefined;
|
||||
|
||||
/**
|
||||
* Error message from validation or redemption
|
||||
*/
|
||||
errorMessage: string | undefined;
|
||||
}
|
||||
|
||||
const initialState: BonRedemptionState = {
|
||||
bonNumber: '',
|
||||
isValidating: false,
|
||||
isRedeeming: false,
|
||||
validationAttempted: false,
|
||||
validatedBon: undefined,
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* SignalStore for managing Bon redemption state.
|
||||
*
|
||||
* Component-scoped store (provided in component providers).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Component({
|
||||
* providers: [BonRedemptionStore]
|
||||
* })
|
||||
* export class CrmFeatureCustomerBonRedemptionComponent {
|
||||
* store = inject(BonRedemptionStore);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const BonRedemptionStore = signalStore(
|
||||
withState(initialState),
|
||||
|
||||
withComputed((store) => ({
|
||||
/**
|
||||
* Whether the search button should be disabled
|
||||
*/
|
||||
disableSearch: computed(
|
||||
() =>
|
||||
store.isValidating() ||
|
||||
store.isRedeeming() ||
|
||||
!store.bonNumber().trim(),
|
||||
),
|
||||
|
||||
/**
|
||||
* Whether the redemption button should be disabled
|
||||
*/
|
||||
disableRedemption: computed(() => {
|
||||
const bon = store.validatedBon();
|
||||
return store.isRedeeming() || store.isValidating() || !bon;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Whether a valid Bon is currently loaded
|
||||
*/
|
||||
hasValidBon: computed(() => {
|
||||
const bon = store.validatedBon();
|
||||
return !!bon;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Whether there is an error message
|
||||
*/
|
||||
hasError: computed(() => {
|
||||
const error = store.errorMessage();
|
||||
return !!error;
|
||||
}),
|
||||
})),
|
||||
|
||||
withMethods((store) => ({
|
||||
/**
|
||||
* Update the Bon number input
|
||||
*/
|
||||
setBonNumber(bonNumber: string): void {
|
||||
patchState(store, {
|
||||
bonNumber,
|
||||
errorMessage: undefined,
|
||||
validatedBon: undefined,
|
||||
validationAttempted: false,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set validation loading state
|
||||
*/
|
||||
setValidating(isValidating: boolean): void {
|
||||
patchState(store, { isValidating });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set redemption loading state
|
||||
*/
|
||||
setRedeeming(isRedeeming: boolean): void {
|
||||
patchState(store, { isRedeeming });
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark that validation was attempted
|
||||
*/
|
||||
setValidationAttempted(validationAttempted: boolean): void {
|
||||
patchState(store, { validationAttempted });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set validated Bon data
|
||||
*/
|
||||
setValidatedBon(validatedBon: ValidatedBon | undefined): void {
|
||||
patchState(store, { validatedBon, errorMessage: undefined });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set error message
|
||||
*/
|
||||
setError(errorMessage: string | undefined): void {
|
||||
patchState(store, { errorMessage, validatedBon: undefined });
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset store to initial state
|
||||
*/
|
||||
reset(): void {
|
||||
patchState(store, initialState);
|
||||
},
|
||||
})),
|
||||
);
|
||||
1
libs/crm/data-access/src/lib/stores/index.ts
Normal file
1
libs/crm/data-access/src/lib/stores/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './bon-redemption.store';
|
||||
7
libs/crm/feature/customer-bon-redemption/README.md
Normal file
7
libs/crm/feature/customer-bon-redemption/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# crm-feature-customer-bon-redemption
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test crm-feature-customer-bon-redemption` to execute the unit tests.
|
||||
34
libs/crm/feature/customer-bon-redemption/eslint.config.cjs
Normal file
34
libs/crm/feature/customer-bon-redemption/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: 'crm',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'crm',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
20
libs/crm/feature/customer-bon-redemption/project.json
Normal file
20
libs/crm/feature/customer-bon-redemption/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "crm-feature-customer-bon-redemption",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/crm/feature/customer-bon-redemption/src",
|
||||
"prefix": "crm",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../../coverage/libs/crm/feature/customer-bon-redemption"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/crm/feature/customer-bon-redemption/src/index.ts
Normal file
1
libs/crm/feature/customer-bon-redemption/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/crm-feature-customer-bon-redemption/crm-feature-customer-bon-redemption.component';
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- Validated Bon Details -->
|
||||
@if (store.validatedBon(); as bon) {
|
||||
<div
|
||||
class="mb-4 max-w-56"
|
||||
data-what="bon-details"
|
||||
[attr.data-which]="bon.bonNumber"
|
||||
role="region"
|
||||
aria-label="Bon Details"
|
||||
>
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<span class="isa-text-body-2-regular text-isa-neutral-600">Bon Datum</span>
|
||||
<span
|
||||
class="isa-text-body-2-bold text-isa-black"
|
||||
data-what="bon-date"
|
||||
[attr.data-which]="bon.bonNumber"
|
||||
>
|
||||
{{ bon.date }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<span class="isa-text-body-2-regular text-isa-neutral-600">Summe</span>
|
||||
<span
|
||||
class="isa-text-body-2-bold text-isa-black"
|
||||
data-what="bon-total"
|
||||
[attr.data-which]="bon.bonNumber"
|
||||
>
|
||||
{{ bon.total | number: '1.2-2' : 'de-DE' }} EUR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { BonRedemptionStore } from '@isa/crm/data-access';
|
||||
|
||||
/**
|
||||
* Smart component for displaying validated Bon details.
|
||||
*
|
||||
* Injects BonRedemptionStore to access validated Bon data.
|
||||
*
|
||||
* Shows:
|
||||
* - Bon date
|
||||
* - Total amount
|
||||
*
|
||||
* @example
|
||||
* <crm-bon-details-display />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'crm-bon-details-display',
|
||||
imports: [DecimalPipe],
|
||||
templateUrl: './bon-details-display.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BonDetailsDisplayComponent {
|
||||
/**
|
||||
* Store for accessing validated Bon data
|
||||
*/
|
||||
readonly store = inject(BonRedemptionStore);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<!-- Input Section -->
|
||||
<div class="mb-4">
|
||||
<ui-text-field size="large" class="w-full bg-isa-neutral-200">
|
||||
<input
|
||||
class="bg-transparent"
|
||||
#bonInput
|
||||
#bonInputControl="ngModel"
|
||||
type="text"
|
||||
uiInputControl
|
||||
[ngModel]="store.bonNumber()"
|
||||
(ngModelChange)="onBonNumberChange($event)"
|
||||
required
|
||||
placeholder="Bon Nummer / Rechnungs-Nummer (HUG.de)*"
|
||||
data-what="bon-number-input"
|
||||
data-which="customer-loyalty"
|
||||
aria-label="Bon Nummer eingeben"
|
||||
aria-required="true"
|
||||
[attr.aria-invalid]="store.errorMessage() ? 'true' : null"
|
||||
[attr.aria-describedby]="store.errorMessage() ? 'bon-error' : null"
|
||||
(keydown.enter)="onValidate()"
|
||||
/>
|
||||
|
||||
@if (store.bonNumber().trim()) {
|
||||
<ui-text-field-clear (click)="onReset()"></ui-text-field-clear>
|
||||
}
|
||||
<button
|
||||
uiTextButton
|
||||
color="strong"
|
||||
size="small"
|
||||
(click)="onValidate()"
|
||||
[pending]="store.isValidating()"
|
||||
[disabled]="bonInputControl.invalid || !cardCode()"
|
||||
data-what="validate-bon-button"
|
||||
data-which="customer-loyalty"
|
||||
aria-label="Bon suchen und validieren"
|
||||
>
|
||||
Bon suchen
|
||||
</button>
|
||||
</ui-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Success or Error Message -->
|
||||
@if (store.hasValidBon()) {
|
||||
<div
|
||||
class="flex items-center gap-2 py-2 mb-4 text-isa-accent-green isa-text-body-2-bold"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-what="bon-success-message"
|
||||
data-which="customer-loyalty"
|
||||
>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span>Bon gefunden</span>
|
||||
</div>
|
||||
} @else if (store.errorMessage()) {
|
||||
<div
|
||||
id="bon-error"
|
||||
class="py-2 mb-4 text-isa-accent-red isa-text-body-2-bold"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
data-what="bon-error-message"
|
||||
data-which="customer-loyalty"
|
||||
>
|
||||
{{ store.errorMessage() }}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import {
|
||||
TextFieldComponent,
|
||||
InputControlDirective,
|
||||
TextFieldClearComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { BonRedemptionStore } from '@isa/crm/data-access';
|
||||
|
||||
/**
|
||||
* Smart component for Bon number input field.
|
||||
*
|
||||
* Injects BonRedemptionStore for state management.
|
||||
*
|
||||
* Features:
|
||||
* - Input field with validation
|
||||
* - Search button (when no result) or clear button (when result/error)
|
||||
* - Success/error message display
|
||||
* - Enter key support for validation
|
||||
*
|
||||
* @example
|
||||
* <crm-bon-input-field
|
||||
* [cardCode]="cardCode"
|
||||
* (validate)="onValidate()"
|
||||
* (clearForm)="onClear()" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'crm-bon-input-field',
|
||||
imports: [
|
||||
FormsModule,
|
||||
TextButtonComponent,
|
||||
TextFieldComponent,
|
||||
TextFieldClearComponent,
|
||||
InputControlDirective,
|
||||
],
|
||||
templateUrl: './bon-input-field.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BonInputFieldComponent {
|
||||
/**
|
||||
* Store for accessing state
|
||||
*/
|
||||
readonly store = inject(BonRedemptionStore);
|
||||
|
||||
/**
|
||||
* Active card code (required for validation)
|
||||
*/
|
||||
readonly cardCode = input<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Emits when validation should be triggered
|
||||
*/
|
||||
readonly validate = output<void>();
|
||||
|
||||
/**
|
||||
* Emits when form should be cleared/reset
|
||||
*/
|
||||
readonly clearForm = output<void>();
|
||||
|
||||
/**
|
||||
* Reference to the input element for focusing
|
||||
*/
|
||||
inputRef = viewChild.required<ElementRef<HTMLInputElement>>('bonInput');
|
||||
|
||||
/**
|
||||
* Effect to focus input when reset is called
|
||||
*/
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Focus when both error and validBon are false (after reset)
|
||||
if (!this.store.hasError() && !this.store.hasValidBon()) {
|
||||
const inputEl = this.inputRef()?.nativeElement;
|
||||
if (inputEl && document.activeElement !== inputEl) {
|
||||
setTimeout(() => inputEl.focus(), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Bon number input changes
|
||||
*/
|
||||
onBonNumberChange(value: string): void {
|
||||
this.store.setBonNumber(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle validation trigger
|
||||
*/
|
||||
onValidate(): void {
|
||||
this.validate.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reset/clear action
|
||||
*/
|
||||
onReset(): void {
|
||||
this.clearForm.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- Redemption Button (Always Visible) -->
|
||||
<button
|
||||
uiButton
|
||||
color="primary"
|
||||
size="large"
|
||||
(click)="onRedeem()"
|
||||
[pending]="store.isRedeeming()"
|
||||
[disabled]="disabled()"
|
||||
data-what="redeem-bon-button"
|
||||
[attr.data-which]="store.validatedBon()?.bonNumber"
|
||||
aria-label="Bon für Kunden verbuchen"
|
||||
>
|
||||
Für Kunden verbuchen
|
||||
</button>
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
import { BonRedemptionStore } from '@isa/crm/data-access';
|
||||
|
||||
/**
|
||||
* Smart component for Bon redemption action button.
|
||||
*
|
||||
* Injects BonRedemptionStore for state management.
|
||||
*
|
||||
* Displays "Für Kunden verbuchen" button with pending state.
|
||||
*
|
||||
* @example
|
||||
* <crm-bon-redemption-button
|
||||
* [disabled]="disabled"
|
||||
* (redeem)="onRedeem()" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'crm-bon-redemption-button',
|
||||
imports: [ButtonComponent],
|
||||
templateUrl: './bon-redemption-button.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BonRedemptionButtonComponent {
|
||||
/**
|
||||
* Store for accessing state
|
||||
*/
|
||||
readonly store = inject(BonRedemptionStore);
|
||||
|
||||
/**
|
||||
* Whether the button should be disabled (from parent)
|
||||
*/
|
||||
readonly disabled = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Emits when redemption should be triggered
|
||||
*/
|
||||
readonly redeem = output<void>();
|
||||
|
||||
/**
|
||||
* Handle redemption button click
|
||||
*/
|
||||
onRedeem(): void {
|
||||
this.redeem.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Scoped element selector for text field */
|
||||
.bon-input-section ui-text-field {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<div
|
||||
class="bg-isa-neutral-200 rounded-2xl p-6 mt-4"
|
||||
data-what="bon-redemption-container"
|
||||
data-which="customer-loyalty"
|
||||
>
|
||||
<h3 class="isa-text-body-1-bold mb-4">Bon nachträglich bepunkten</h3>
|
||||
|
||||
<!-- Input Field with Search/Clear Button and Validation Messages -->
|
||||
<crm-bon-input-field
|
||||
[cardCode]="cardCode()"
|
||||
(validate)="validateBon()"
|
||||
(clearForm)="resetForm()"
|
||||
/>
|
||||
|
||||
<!-- Validated Bon Details -->
|
||||
<crm-bon-details-display />
|
||||
|
||||
<!-- Redemption Button (Always Visible) -->
|
||||
<crm-bon-redemption-button
|
||||
[disabled]="disableRedemption()"
|
||||
(redeem)="redeemBon()"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CrmFeatureCustomerBonRedemptionComponent } from './crm-feature-customer-bon-redemption.component';
|
||||
|
||||
describe('CrmFeatureCustomerBonRedemptionComponent', () => {
|
||||
let component: CrmFeatureCustomerBonRedemptionComponent;
|
||||
let fixture: ComponentFixture<CrmFeatureCustomerBonRedemptionComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CrmFeatureCustomerBonRedemptionComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CrmFeatureCustomerBonRedemptionComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
inject,
|
||||
computed,
|
||||
effect,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
injectFeedbackDialog,
|
||||
injectFeedbackErrorDialog,
|
||||
} from '@isa/ui/dialog';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import {
|
||||
CustomerBonRedemptionFacade,
|
||||
CustomerBonCheckResource,
|
||||
BonRedemptionStore,
|
||||
} from '@isa/crm/data-access';
|
||||
import { ResponseArgsError } from '@isa/common/data-access';
|
||||
import { BonInputFieldComponent } from './components/bon-input-field/bon-input-field.component';
|
||||
import { BonDetailsDisplayComponent } from './components/bon-details-display/bon-details-display.component';
|
||||
import { BonRedemptionButtonComponent } from './components/bon-redemption-button/bon-redemption-button.component';
|
||||
|
||||
/**
|
||||
* Component for redeeming customer receipts (Bon) for loyalty points.
|
||||
*
|
||||
* Allows users to:
|
||||
* 1. Enter a Bon number
|
||||
* 2. Validate the Bon exists and is valid
|
||||
* 3. View Bon summary (date, total)
|
||||
* 4. Redeem the Bon for customer points
|
||||
*
|
||||
* @example
|
||||
* <crm-customer-bon-redemption [cardCode]="activeCardCode()" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'crm-customer-bon-redemption',
|
||||
imports: [
|
||||
BonInputFieldComponent,
|
||||
BonDetailsDisplayComponent,
|
||||
BonRedemptionButtonComponent,
|
||||
],
|
||||
providers: [BonRedemptionStore, CustomerBonCheckResource],
|
||||
templateUrl: './crm-feature-customer-bon-redemption.component.html',
|
||||
styleUrl: './crm-feature-customer-bon-redemption.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CrmFeatureCustomerBonRedemptionComponent {
|
||||
#logger = logger(() => ({
|
||||
component: 'CrmFeatureCustomerBonRedemptionComponent',
|
||||
cardCode: this.cardCode(),
|
||||
}));
|
||||
|
||||
#bonCheckResource = inject(CustomerBonCheckResource);
|
||||
#bonFacade = inject(CustomerBonRedemptionFacade);
|
||||
#errorFeedbackDialog = injectFeedbackErrorDialog();
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
|
||||
/**
|
||||
* Store for managing Bon redemption state
|
||||
*/
|
||||
readonly store = inject(BonRedemptionStore);
|
||||
|
||||
/**
|
||||
* Active loyalty card code for the customer
|
||||
*/
|
||||
readonly cardCode = input<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Computed: Disable redemption if no card code
|
||||
*/
|
||||
readonly disableRedemption = computed(
|
||||
() => this.store.disableRedemption() || !this.cardCode(),
|
||||
);
|
||||
|
||||
readonly redeemed = output<void>();
|
||||
|
||||
/**
|
||||
* Constructor - sets up effects to sync resource state with store
|
||||
*/
|
||||
constructor() {
|
||||
// Sync resource loading state with store
|
||||
effect(() => {
|
||||
const isLoading = this.#bonCheckResource.resource.isLoading();
|
||||
this.store.setValidating(isLoading);
|
||||
});
|
||||
|
||||
// Sync resource results with store
|
||||
effect(() => {
|
||||
const result = this.#bonCheckResource.resource.value();
|
||||
const error = this.#bonCheckResource.resource.error();
|
||||
const isLoading = this.#bonCheckResource.resource.isLoading();
|
||||
const validationAttempted = this.store.validationAttempted();
|
||||
|
||||
// Only process results if validation was attempted
|
||||
if (!validationAttempted || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle validation result
|
||||
if (result) {
|
||||
this.store.setValidatedBon({
|
||||
bonNumber: this.store.bonNumber(),
|
||||
date: result.date ?? '',
|
||||
total: result.total ?? 0,
|
||||
});
|
||||
}
|
||||
// Check if validation returned no result
|
||||
else if (!error && !result) {
|
||||
this.store.setError('Keine verpunktung möglich');
|
||||
}
|
||||
// Handle API errors
|
||||
else if (error) {
|
||||
let errorMsg = 'Bon-Validierung fehlgeschlagen';
|
||||
if (error instanceof ResponseArgsError) {
|
||||
errorMsg = error.message || errorMsg;
|
||||
} else if (error instanceof Error) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
this.store.setError(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the entered Bon number
|
||||
*/
|
||||
validateBon(): void {
|
||||
const cardCode = this.cardCode();
|
||||
const bonNr = this.store.bonNumber().trim();
|
||||
|
||||
if (!cardCode || !bonNr) {
|
||||
this.#logger.warn(
|
||||
'Cannot validate Bon: missing required parameters',
|
||||
() => ({
|
||||
hasCardCode: !!cardCode,
|
||||
hasBonNr: !!bonNr,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logger.debug('Triggering Bon validation', () => ({
|
||||
cardCode,
|
||||
bonNr,
|
||||
}));
|
||||
this.store.setValidationAttempted(true);
|
||||
this.#bonCheckResource.params({ cardCode, bonNr });
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem the validated Bon for customer points
|
||||
*/
|
||||
async redeemBon() {
|
||||
this.store.setRedeeming(true);
|
||||
|
||||
try {
|
||||
const cardCode = this.cardCode();
|
||||
const bonNr = this.store.bonNumber().trim();
|
||||
const validatedBon = this.store.validatedBon();
|
||||
|
||||
if (!cardCode) {
|
||||
throw new Error('Kein Karten-Code vorhanden');
|
||||
}
|
||||
|
||||
if (!bonNr || !validatedBon) {
|
||||
throw new Error('Bon muss zuerst validiert werden');
|
||||
}
|
||||
|
||||
this.#logger.debug('Redeeming Bon', () => ({ cardCode, bonNr }));
|
||||
|
||||
const success = await this.#bonFacade.addBon({ cardCode, bonNr });
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Bon-Einlösung fehlgeschlagen');
|
||||
}
|
||||
|
||||
this.#logger.info('Bon redeemed successfully', () => ({
|
||||
bonNr,
|
||||
total: validatedBon.total,
|
||||
}));
|
||||
|
||||
this.#feedbackDialog({
|
||||
data: {
|
||||
message: 'Bon wurde erfolgreich gebucht',
|
||||
autoClose: true,
|
||||
autoCloseDelay: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form
|
||||
this.resetForm();
|
||||
this.redeemed.emit();
|
||||
} catch (error: unknown) {
|
||||
this.#logger.error('Bon redemption failed', error as Error, () => ({
|
||||
bonNr: this.store.bonNumber(),
|
||||
}));
|
||||
|
||||
let errorMsg = 'Bon-Einlösung fehlgeschlagen';
|
||||
|
||||
if (error instanceof ResponseArgsError) {
|
||||
errorMsg = error.message || errorMsg;
|
||||
} else if (error instanceof Error) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
|
||||
this.#errorFeedbackDialog({
|
||||
data: {
|
||||
errorMessage: errorMsg,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this.store.setRedeeming(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the form to initial state
|
||||
*/
|
||||
resetForm(): void {
|
||||
this.store.reset();
|
||||
this.#bonCheckResource.reset();
|
||||
}
|
||||
}
|
||||
13
libs/crm/feature/customer-bon-redemption/src/test-setup.ts
Normal file
13
libs/crm/feature/customer-bon-redemption/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/crm/feature/customer-bon-redemption/tsconfig.json
Normal file
30
libs/crm/feature/customer-bon-redemption/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/crm/feature/customer-bon-redemption/tsconfig.lib.json
Normal file
27
libs/crm/feature/customer-bon-redemption/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/crm/feature/customer-bon-redemption/tsconfig.spec.json
Normal file
29
libs/crm/feature/customer-bon-redemption/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"]
|
||||
}
|
||||
35
libs/crm/feature/customer-bon-redemption/vite.config.mts
Normal file
35
libs/crm/feature/customer-bon-redemption/vite.config.mts
Normal file
@@ -0,0 +1,35 @@
|
||||
/// <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/crm/feature/customer-bon-redemption',
|
||||
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-crm-feature-customer-bon-redemption.xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory:
|
||||
'../../../../coverage/libs/crm/feature/customer-bon-redemption',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
|
||||
<!-- Cards carousel -->
|
||||
<crm-customer-cards-carousel [cards]="cardList" class="max-w-[657px]" />
|
||||
<crm-customer-cards-carousel [cards]="cardList" class="w-full" />
|
||||
|
||||
<!-- Action buttons (TODO: implement) -->
|
||||
<div class="flex gap-4" data-what="card-actions">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.ui-text-field-clear {
|
||||
ui-icon-button {
|
||||
@apply text-isa-neutral-900;
|
||||
}
|
||||
}
|
||||
.ui-text-field-clear {
|
||||
ui-icon-button {
|
||||
@apply text-isa-neutral-900 bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { TextFieldComponent } from './text-field.component';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaActionClose } from '@isa/icons';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-text-field-clear',
|
||||
templateUrl: './text-field-clear.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
host: {
|
||||
'[class]': '["ui-text-field-clear", sizeClass()]',
|
||||
},
|
||||
providers: [provideIcons({ isaActionClose })],
|
||||
imports: [IconButtonComponent],
|
||||
})
|
||||
export class TextFieldClearComponent {
|
||||
hostComponent = inject(TextFieldComponent, { host: true });
|
||||
|
||||
size = this.hostComponent.size;
|
||||
|
||||
sizeClass = computed(() => {
|
||||
return `ui-text-field-clear__${this.size()}`;
|
||||
});
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { TextFieldComponent } from './text-field.component';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaActionClose } from '@isa/icons';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-text-field-clear',
|
||||
templateUrl: './text-field-clear.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
host: {
|
||||
'[class]': '["ui-text-field-clear", sizeClass()]',
|
||||
},
|
||||
providers: [provideIcons({ isaActionClose })],
|
||||
imports: [IconButtonComponent],
|
||||
})
|
||||
export class TextFieldClearComponent {
|
||||
hostComponent = inject(TextFieldComponent, { host: true });
|
||||
|
||||
size = this.hostComponent.size;
|
||||
|
||||
sizeClass = computed(() => {
|
||||
return `ui-text-field-clear__${this.size()}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
|
||||
"@isa/core/tabs": ["libs/core/tabs/src/index.ts"],
|
||||
"@isa/crm/data-access": ["libs/crm/data-access/src/index.ts"],
|
||||
"@isa/crm/feature/customer-bon-redemption": [
|
||||
"libs/crm/feature/customer-bon-redemption/src/index.ts"
|
||||
],
|
||||
"@isa/crm/feature/customer-booking": [
|
||||
"libs/crm/feature/customer-booking/src/index.ts"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user