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
This commit is contained in:
Lorenz Hilpert
2025-11-21 13:40:06 +00:00
committed by Nino Righi
parent 644c33ddc3
commit 5f1d3a2c7b
4 changed files with 186 additions and 169 deletions

View File

@@ -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<typeof setTimeout>;
#logger = logger(() => ({
component: 'CrmFeatureCustomerBookingComponent',
}));
#customerCardBookingFacade = inject(CustomerCardBookingFacade);
#bookingReasonsResource = inject(CustomerBookingReasonsResource);
#transactionResource = inject(CustomerCardTransactionsResource);
#errorFeedbackDialog = injectFeedbackErrorDialog();
#feedbackDialog = injectFeedbackDialog();
readonly cardCode = input<string | undefined>(undefined);
readonly bookingReasons = this.#bookingReasonsResource.resource.value;
readonly bookingReasonsLoading =
this.#bookingReasonsResource.resource.isLoading;
points = signal<number | undefined>(undefined);
selectedReasonKey = signal<string | undefined>(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<string | undefined>(undefined);
readonly booked = output<void>();
readonly bookingReasons = this.#bookingReasonsResource.resource.value;
readonly bookingReasonsLoading =
this.#bookingReasonsResource.resource.isLoading;
points = signal<number | undefined>(undefined);
selectedReasonKey = signal<string | undefined>(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);
}
}

View File

@@ -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<string | undefined>(undefined);
readonly reload = output<void>();
/**
* 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();
}
}