From 5f1d3a2c7ba7e2dc07b4a1e850623ec588c1897c Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Fri, 21 Nov 2025 13:40:06 +0000 Subject: [PATCH] Merged PR 2040: fix(crm): prevent duplicate reload of loyalty points fix(crm): prevent duplicate reload of loyalty points Refactored reload mechanism to use parent-managed pattern: - Child components emit events instead of reloading directly - Parent coordinates reload of both transactions and bonus cards - Added loading guards to prevent concurrent requests - Added JSDoc documentation to public methods Closes #5497 Related work items: #5497 --- .../kundenkarte-main-view.component.html | 14 +- .../kundenkarte-main-view.component.ts | 23 +- .../crm-feature-customer-booking.component.ts | 308 +++++++++--------- ...re-customer-card-transactions.component.ts | 10 +- 4 files changed, 186 insertions(+), 169 deletions(-) diff --git a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html index 73e9ad0c6..19229a385 100644 --- a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html +++ b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.html @@ -17,10 +17,18 @@ + + - - } { - const cards = this._bonusCardsResource.resource.value(); + const cards = this.#bonusCardsResource.resource.value(); const firstActiveCard = cards?.find((card) => card.isActive); return firstActiveCard?.code; }); @@ -83,14 +86,24 @@ export class KundenkarteMainViewComponent implements OnDestroy { effect(() => { const customerId = this.customerId(); if (customerId) { - this._bonusCardsResource.params({ customerId: Number(customerId) }); + this.#bonusCardsResource.params({ customerId: Number(customerId) }); } }); } - reloadCardTransactions() { + /** + * Reloads both card transactions and bonus cards resources after a 500ms delay. + * Only triggers reload if the resource is not currently loading to prevent concurrent requests. + */ + reloadCardTransactionsAndCards() { this.#reloadTimeoutId = setTimeout(() => { - this.#cardTransactionsResource.resource.reload(); + if (!this.#cardTransactionsResource.resource.isLoading()) { + this.#cardTransactionsResource.resource.reload(); + } + + if (!this.#bonusCardsResource.resource.isLoading()) { + this.#bonusCardsResource.resource.reload(); + } }, 500); } diff --git a/libs/crm/feature/customer-booking/src/lib/crm-feature-customer-booking/crm-feature-customer-booking.component.ts b/libs/crm/feature/customer-booking/src/lib/crm-feature-customer-booking/crm-feature-customer-booking.component.ts index 63feb2245..e85a006a5 100644 --- a/libs/crm/feature/customer-booking/src/lib/crm-feature-customer-booking/crm-feature-customer-booking.component.ts +++ b/libs/crm/feature/customer-booking/src/lib/crm-feature-customer-booking/crm-feature-customer-booking.component.ts @@ -1,159 +1,149 @@ -import { - ChangeDetectionStrategy, - Component, - signal, - computed, - input, - inject, - OnDestroy, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { PositiveIntegerInputDirective } from '@isa/utils/positive-integer-input'; -import { ButtonComponent } from '@isa/ui/buttons'; -import { - CustomerBookingReasonsResource, - CustomerCardBookingFacade, - CustomerCardTransactionsResource, -} from '@isa/crm/data-access'; -import { - injectFeedbackDialog, - injectFeedbackErrorDialog, -} from '@isa/ui/dialog'; -import { logger } from '@isa/core/logging'; -import { - DropdownButtonComponent, - DropdownOptionComponent, -} from '@isa/ui/input-controls'; -import { TooltipIconComponent } from '@isa/ui/tooltip'; - -@Component({ - selector: 'crm-customer-booking', - imports: [ - FormsModule, - PositiveIntegerInputDirective, - ButtonComponent, - DropdownButtonComponent, - DropdownOptionComponent, - TooltipIconComponent, - ], - templateUrl: './crm-feature-customer-booking.component.html', - styleUrl: './crm-feature-customer-booking.component.css', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [CustomerBookingReasonsResource], -}) -export class CrmFeatureCustomerBookingComponent implements OnDestroy { - #reloadTimeoutId?: ReturnType; - #logger = logger(() => ({ - component: 'CrmFeatureCustomerBookingComponent', - })); - #customerCardBookingFacade = inject(CustomerCardBookingFacade); - #bookingReasonsResource = inject(CustomerBookingReasonsResource); - #transactionResource = inject(CustomerCardTransactionsResource); - #errorFeedbackDialog = injectFeedbackErrorDialog(); - #feedbackDialog = injectFeedbackDialog(); - readonly cardCode = input(undefined); - - readonly bookingReasons = this.#bookingReasonsResource.resource.value; - readonly bookingReasonsLoading = - this.#bookingReasonsResource.resource.isLoading; - - points = signal(undefined); - selectedReasonKey = signal(undefined); - isBooking = signal(false); - - selectedReason = computed(() => { - const key = this.selectedReasonKey(); - const reasons = this.bookingReasons(); - return reasons?.find((r) => r.key === key); - }); - - calculatedPoints = computed(() => { - const reason = this.selectedReason(); - const pointsValue = this.points(); - if (!reason || !pointsValue) return 0; - return pointsValue * (reason.value ?? 1); - }); - - disableBooking = computed(() => { - return ( - this.isBooking() || - this.bookingReasonsLoading() || - !this.selectedReasonKey() || - !this.points() || - this.points() === 0 - ); - }); - - dropdownLabel = computed(() => { - const reason = this.selectedReason()?.label; - return reason ?? 'Buchungstyp'; - }); - - async booking() { - this.isBooking.set(true); - try { - const cardCode = this.cardCode(); - const reason = this.selectedReason(); - const calculatedPoints = this.calculatedPoints(); - - if (!cardCode) { - throw new Error('Kein Karten-Code vorhanden'); - } - - if (!reason) { - throw new Error('Kein Buchungsgrund ausgewählt'); - } - - if (calculatedPoints === 0) { - throw new Error('Punktezahl muss größer als 0 sein'); - } - - const currentBookingPartnerStore = - await this.#customerCardBookingFacade.fetchCurrentBookingPartnerStore(); - const storeId = currentBookingPartnerStore?.key; - - await this.#customerCardBookingFacade.addBooking({ - cardCode, - booking: { - points: calculatedPoints, - reason: reason.key, - storeId: storeId, - }, - }); - - this.#feedbackDialog({ - data: { - message: `${reason.label} erfolgreich durchgeführt`, - }, - }); - this.reloadTransactionHistory(); - } catch (error: any) { - this.#logger.error('Booking Failed', () => ({ error })); - this.#errorFeedbackDialog({ - data: { - errorMessage: error?.message ?? 'Buchen/Stornieren fehlgeschlagen', - }, - }); - } finally { - this.isBooking.set(false); - this.resetInputs(); - } - } - - resetInputs() { - this.points.set(undefined); - this.selectedReasonKey.set(undefined); - } - - reloadTransactionHistory() { - this.#reloadTimeoutId = setTimeout(() => { - this.#transactionResource.resource.reload(); - }, 500); - } - - ngOnDestroy(): void { - if (this.#reloadTimeoutId) { - clearTimeout(this.#reloadTimeoutId); - } - } -} +import { + ChangeDetectionStrategy, + Component, + signal, + computed, + input, + inject, + output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PositiveIntegerInputDirective } from '@isa/utils/positive-integer-input'; +import { ButtonComponent } from '@isa/ui/buttons'; +import { + CustomerBookingReasonsResource, + CustomerCardBookingFacade, +} from '@isa/crm/data-access'; +import { + injectFeedbackDialog, + injectFeedbackErrorDialog, +} from '@isa/ui/dialog'; +import { logger } from '@isa/core/logging'; +import { + DropdownButtonComponent, + DropdownOptionComponent, +} from '@isa/ui/input-controls'; +import { TooltipIconComponent } from '@isa/ui/tooltip'; + +@Component({ + selector: 'crm-customer-booking', + imports: [ + FormsModule, + PositiveIntegerInputDirective, + ButtonComponent, + DropdownButtonComponent, + DropdownOptionComponent, + TooltipIconComponent, + ], + templateUrl: './crm-feature-customer-booking.component.html', + styleUrl: './crm-feature-customer-booking.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [CustomerBookingReasonsResource], +}) +export class CrmFeatureCustomerBookingComponent { + #logger = logger(() => ({ + component: 'CrmFeatureCustomerBookingComponent', + })); + #customerCardBookingFacade = inject(CustomerCardBookingFacade); + #bookingReasonsResource = inject(CustomerBookingReasonsResource); + #errorFeedbackDialog = injectFeedbackErrorDialog(); + #feedbackDialog = injectFeedbackDialog(); + readonly cardCode = input(undefined); + + readonly booked = output(); + + readonly bookingReasons = this.#bookingReasonsResource.resource.value; + readonly bookingReasonsLoading = + this.#bookingReasonsResource.resource.isLoading; + + points = signal(undefined); + selectedReasonKey = signal(undefined); + isBooking = signal(false); + + selectedReason = computed(() => { + const key = this.selectedReasonKey(); + const reasons = this.bookingReasons(); + return reasons?.find((r) => r.key === key); + }); + + calculatedPoints = computed(() => { + const reason = this.selectedReason(); + const pointsValue = this.points(); + if (!reason || !pointsValue) return 0; + return pointsValue * (reason.value ?? 1); + }); + + disableBooking = computed(() => { + return ( + this.isBooking() || + this.bookingReasonsLoading() || + !this.selectedReasonKey() || + !this.points() || + this.points() === 0 + ); + }); + + dropdownLabel = computed(() => { + const reason = this.selectedReason()?.label; + return reason ?? 'Buchungstyp'; + }); + + async booking() { + this.isBooking.set(true); + try { + const cardCode = this.cardCode(); + const reason = this.selectedReason(); + const calculatedPoints = this.calculatedPoints(); + + if (!cardCode) { + throw new Error('Kein Karten-Code vorhanden'); + } + + if (!reason) { + throw new Error('Kein Buchungsgrund ausgewählt'); + } + + if (calculatedPoints === 0) { + throw new Error('Punktezahl muss größer als 0 sein'); + } + + const currentBookingPartnerStore = + await this.#customerCardBookingFacade.fetchCurrentBookingPartnerStore(); + const storeId = currentBookingPartnerStore?.key; + + await this.#customerCardBookingFacade.addBooking({ + cardCode, + booking: { + points: calculatedPoints, + reason: reason.key, + storeId: storeId, + }, + }); + + this.#feedbackDialog({ + data: { + message: `${reason.label} erfolgreich durchgeführt`, + }, + }); + this.booked.emit(); + } catch (error: unknown) { + this.#logger.error('Booking Failed', () => ({ error })); + this.#errorFeedbackDialog({ + data: { + errorMessage: + error instanceof Error + ? error.message + : 'Buchen/Stornieren fehlgeschlagen', + }, + }); + } finally { + this.isBooking.set(false); + this.resetInputs(); + } + } + + resetInputs() { + this.points.set(undefined); + this.selectedReasonKey.set(undefined); + } +} diff --git a/libs/crm/feature/customer-card-transactions/src/lib/crm-feature-customer-card-transactions/crm-feature-customer-card-transactions.component.ts b/libs/crm/feature/customer-card-transactions/src/lib/crm-feature-customer-card-transactions/crm-feature-customer-card-transactions.component.ts index 19c5ab9d1..37257f171 100644 --- a/libs/crm/feature/customer-card-transactions/src/lib/crm-feature-customer-card-transactions/crm-feature-customer-card-transactions.component.ts +++ b/libs/crm/feature/customer-card-transactions/src/lib/crm-feature-customer-card-transactions/crm-feature-customer-card-transactions.component.ts @@ -5,6 +5,7 @@ import { input, effect, computed, + output, } from '@angular/core'; import { DatePipe, DecimalPipe } from '@angular/common'; import { CdkTableModule } from '@angular/cdk/table'; @@ -53,6 +54,8 @@ export class CrmFeatureCustomerCardTransactionsComponent { */ readonly cardCode = input(undefined); + readonly reload = output(); + /** * Exposed resource signals for template */ @@ -90,8 +93,11 @@ export class CrmFeatureCustomerCardTransactionsComponent { }); } + /** + * Emits reload event to notify parent component. + * Parent is responsible for reloading resources to coordinate multi-resource updates. + */ refresh() { - this.#transactionsResource.params({ cardCode: this.cardCode() }); - this.#transactionsResource.resource.reload(); + this.reload.emit(); } }