Compare commits

...

4 Commits

Author SHA1 Message Date
Lorenz Hilpert
50363790c1 chore: update dependencies to latest versions
This update includes various minor improvements and security patches
to ensure the application remains stable and secure.
2025-11-18 11:16:55 +01:00
Lorenz Hilpert
65491fb0d4 Merge branch 'develop' into feature/5315-Crm-Card-Booking 2025-11-18 11:04:46 +01:00
Nino
1f2eff8615 feat(crm-customer-booking): add loyalty card booking component
Implement new component for customer loyalty card credit/debit bookings with booking type selection and real-time transaction updates. Includes automatic reload of transaction history after successful bookings.

Key changes:
- Add CrmFeatureCustomerBookingComponent with booking form UI
- Create CustomerCardBookingFacade for booking API calls
- Add CustomerBookingReasonsResource for loading booking types
- Extend CrmSearchService with booking methods (addBooking, fetchBookingReasons, fetchCurrentBookingPartnerStore)
- Add AddBookingSchema with Zod validation
- Integrate component into KundenkarteMainViewComponent
- Update CustomerCardTransactionsResource to providedIn: 'root' for shared access
- Improve transaction list UX (hide header/center empty state when no data)

Technical details:
- New library: @isa/crm/feature/customer-booking (Vitest-based)
- Signals-based state management with computed properties
- Automatic points calculation based on booking type multiplier
- Error handling with feedback dialogs
- 500ms delay before transaction reload to ensure API consistency
- Data attributes for E2E testing (data-what, data-which)

Ref: #5315
2025-11-17 18:31:48 +01:00
Nino
442707774b feat(swagger-crm-api): Swagger CRM Api Update, Refs: #5333 2025-11-17 16:56:16 +01:00
42 changed files with 39285 additions and 38454 deletions

View File

@@ -10,6 +10,7 @@
[tabId]="processId$ | async"
class="mt-4"
/>
<crm-customer-booking [cardCode]="firstActiveCardCode()" class="mt-4" />
<crm-customer-card-transactions
[cardCode]="firstActiveCardCode()"
class="mt-8"

View File

@@ -15,6 +15,7 @@ import { CustomerLoyaltyCardsComponent } from '@isa/crm/feature/customer-loyalty
import { CrmFeatureCustomerCardTransactionsComponent } from '@isa/crm/feature/customer-card-transactions';
import { toSignal } from '@angular/core/rxjs-interop';
import { CustomerBonusCardsResource } from '@isa/crm/data-access';
import { CrmFeatureCustomerBookingComponent } from '@isa/crm/feature/customer-booking';
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
@Component({
@@ -28,6 +29,7 @@ import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
AsyncPipe,
CustomerLoyaltyCardsComponent,
CrmFeatureCustomerCardTransactionsComponent,
CrmFeatureCustomerBookingComponent,
ScrollTopButtonComponent,
],
providers: [CustomerBonusCardsResource],

View File

@@ -76,6 +76,15 @@ export { EntityDTOBaseOfCustomerInfoDTOAndICustomer } from './models/entity-dtob
export { QueryTokenDTO } from './models/query-token-dto';
export { QueryTokenDTO2 } from './models/query-token-dto2';
export { ResponseArgsOfCustomerDTO } from './models/response-args-of-customer-dto';
export { ResponseArgsOfAccountDetailsDTO } from './models/response-args-of-account-details-dto';
export { AccountDetailsDTO } from './models/account-details-dto';
export { AccountBalanceDTO } from './models/account-balance-dto';
export { IdentifierDTO } from './models/identifier-dto';
export { StateLevelDTO } from './models/state-level-dto';
export { MembershipDetailsDTO } from './models/membership-details-dto';
export { CustomPropertyDTO } from './models/custom-property-dto';
export { OptinDTO } from './models/optin-dto';
export { AddLoyaltyCardValues } from './models/add-loyalty-card-values';
export { SaveCustomerValues } from './models/save-customer-values';
export { ResponseArgsOfAssignedPayerDTO } from './models/response-args-of-assigned-payer-dto';
export { ResponseArgsOfBoolean } from './models/response-args-of-boolean';
@@ -92,7 +101,8 @@ export { DiffDTO } from './models/diff-dto';
export { ResponseArgsOfIQueryResultOfLoyaltyBookingInfoDTO } from './models/response-args-of-iquery-result-of-loyalty-booking-info-dto';
export { IQueryResultOfLoyaltyBookingInfoDTO } from './models/iquery-result-of-loyalty-booking-info-dto';
export { LoyaltyBookingInfoDTO } from './models/loyalty-booking-info-dto';
export { ResponseArgsOfIEnumerableOfString } from './models/response-args-of-ienumerable-of-string';
export { ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger } from './models/response-args-of-ienumerable-of-key-value-dtoof-string-and-integer';
export { KeyValueDTOOfStringAndInteger } from './models/key-value-dtoof-string-and-integer';
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';

View File

@@ -0,0 +1,5 @@
/* tslint:disable */
export interface AccountBalanceDTO {
lockedPoints: number;
points: number;
}

View File

@@ -0,0 +1,14 @@
/* tslint:disable */
import { AccountBalanceDTO } from './account-balance-dto';
import { IdentifierDTO } from './identifier-dto';
import { StateLevelDTO } from './state-level-dto';
import { MembershipDetailsDTO } from './membership-details-dto';
export interface AccountDetailsDTO {
accountBalance?: AccountBalanceDTO;
accountId?: string;
createdAt?: string;
identifiers?: Array<IdentifierDTO>;
level?: StateLevelDTO;
memberships?: Array<MembershipDetailsDTO>;
status?: string;
}

View File

@@ -0,0 +1,8 @@
/* tslint:disable */
export interface AddLoyaltyCardValues {
/**
* Card code
*/
cardCode?: string;
}

View File

@@ -0,0 +1,5 @@
/* tslint:disable */
export interface CustomPropertyDTO {
name?: string;
value?: string;
}

View File

@@ -0,0 +1,8 @@
/* tslint:disable */
export interface IdentifierDTO {
code?: string;
displayCode?: string;
identifierId?: string;
status?: string;
type?: string;
}

View File

@@ -0,0 +1,12 @@
/* tslint:disable */
export interface KeyValueDTOOfStringAndInteger {
command?: string;
description?: string;
enabled?: boolean;
group?: string;
key?: string;
label?: string;
selected?: boolean;
sort?: number;
value: number;
}

View File

@@ -0,0 +1,19 @@
/* tslint:disable */
import { CustomPropertyDTO } from './custom-property-dto';
import { OptinDTO } from './optin-dto';
export interface MembershipDetailsDTO {
birthDate?: string;
city?: string;
countryCode?: string;
customProperties?: Array<CustomPropertyDTO>;
emailAddress?: string;
familyName?: string;
genderCode?: string;
givenName?: string;
memberRole?: string;
membershipId?: string;
optins?: Array<OptinDTO>;
streetHouseNo?: string;
userId?: string;
zipCode?: string;
}

View File

@@ -0,0 +1,5 @@
/* tslint:disable */
export interface OptinDTO {
flag: boolean;
type?: string;
}

View File

@@ -0,0 +1,6 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
import { AccountDetailsDTO } from './account-details-dto';
export interface ResponseArgsOfAccountDetailsDTO extends ResponseArgs{
result?: AccountDetailsDTO;
}

View File

@@ -0,0 +1,6 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
import { KeyValueDTOOfStringAndInteger } from './key-value-dtoof-string-and-integer';
export interface ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger extends ResponseArgs{
result?: Array<KeyValueDTOOfStringAndInteger>;
}

View File

@@ -1,5 +0,0 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
export interface ResponseArgsOfIEnumerableOfString extends ResponseArgs{
result?: Array<string>;
}

View File

@@ -0,0 +1,10 @@
/* tslint:disable */
export interface StateLevelDTO {
currentStatePoints?: number;
name?: string;
neededStatePoints?: number;
neededStatePointsNextLevel?: number;
requiredPointsToMaintainLevel?: number;
requiredPointsToReachNextLevel?: number;
validTo?: string;
}

View File

@@ -17,6 +17,8 @@ import { ResponseArgsOfCustomerDTO } from '../models/response-args-of-customer-d
import { SaveCustomerValues } from '../models/save-customer-values';
import { CustomerDTO } from '../models/customer-dto';
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
import { ResponseArgsOfAccountDetailsDTO } from '../models/response-args-of-account-details-dto';
import { AddLoyaltyCardValues } from '../models/add-loyalty-card-values';
import { ResponseArgsOfAssignedPayerDTO } from '../models/response-args-of-assigned-payer-dto';
import { ResponseArgsOfIEnumerableOfCustomerInfoDTO } from '../models/response-args-of-ienumerable-of-customer-info-dto';
import { ResponseArgsOfIEnumerableOfBonusCardInfoDTO } from '../models/response-args-of-ienumerable-of-bonus-card-info-dto';
@@ -35,6 +37,7 @@ class CustomerService extends __BaseService {
static readonly CustomerUpdateCustomerPath = '/customer/{customerId}';
static readonly CustomerPatchCustomerPath = '/customer/{customerId}';
static readonly CustomerDeleteCustomerPath = '/customer/{customerId}';
static readonly CustomerAddLoyaltyCardPath = '/customer/{customerId}/loyalty/add-card';
static readonly CustomerCreateCustomerPath = '/customer';
static readonly CustomerAddPayerReferencePath = '/customer/{customerId}/payer';
static readonly CustomerDeactivateCustomerPath = '/customer/{customerId}/deactivate';
@@ -389,6 +392,56 @@ class CustomerService extends __BaseService {
);
}
/**
* Kundenkarte hinzufügen
* @param params The `CustomerService.CustomerAddLoyaltyCardParams` containing the following parameters:
*
* - `loyaltyCardValues`:
*
* - `customerId`:
*
* - `locale`:
*/
CustomerAddLoyaltyCardResponse(params: CustomerService.CustomerAddLoyaltyCardParams): __Observable<__StrictHttpResponse<ResponseArgsOfAccountDetailsDTO>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
__body = params.loyaltyCardValues;
if (params.locale != null) __params = __params.set('locale', params.locale.toString());
let req = new HttpRequest<any>(
'POST',
this.rootUrl + `/customer/${encodeURIComponent(String(params.customerId))}/loyalty/add-card`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfAccountDetailsDTO>;
})
);
}
/**
* Kundenkarte hinzufügen
* @param params The `CustomerService.CustomerAddLoyaltyCardParams` containing the following parameters:
*
* - `loyaltyCardValues`:
*
* - `customerId`:
*
* - `locale`:
*/
CustomerAddLoyaltyCard(params: CustomerService.CustomerAddLoyaltyCardParams): __Observable<ResponseArgsOfAccountDetailsDTO> {
return this.CustomerAddLoyaltyCardResponse(params).pipe(
__map(_r => _r.body as ResponseArgsOfAccountDetailsDTO)
);
}
/**
* Anlage eines neuen Kunden
* @param payload Kundendaten
@@ -861,6 +914,15 @@ module CustomerService {
deletionComment?: null | string;
}
/**
* Parameters for CustomerAddLoyaltyCard
*/
export interface CustomerAddLoyaltyCardParams {
loyaltyCardValues: AddLoyaltyCardValues;
customerId: number;
locale?: null | string;
}
/**
* Parameters for CustomerAddPayerReference
*/

View File

@@ -10,7 +10,7 @@ import { map as __map, filter as __filter } from 'rxjs/operators';
import { ResponseArgsOfIQueryResultOfLoyaltyBookingInfoDTO } from '../models/response-args-of-iquery-result-of-loyalty-booking-info-dto';
import { ResponseArgsOfLoyaltyBookingInfoDTO } from '../models/response-args-of-loyalty-booking-info-dto';
import { LoyaltyBookingValues } from '../models/loyalty-booking-values';
import { ResponseArgsOfIEnumerableOfString } from '../models/response-args-of-ienumerable-of-string';
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 { LoyaltyBonValues } from '../models/loyalty-bon-values';
@@ -133,7 +133,7 @@ class LoyaltyCardService extends __BaseService {
/**
* Booking reason / Buchungsgründe
*/
LoyaltyCardBookingReasonResponse(): __Observable<__StrictHttpResponse<ResponseArgsOfIEnumerableOfString>> {
LoyaltyCardBookingReasonResponse(): __Observable<__StrictHttpResponse<ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
@@ -150,16 +150,16 @@ class LoyaltyCardService extends __BaseService {
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfIEnumerableOfString>;
return _r as __StrictHttpResponse<ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger>;
})
);
}
/**
* Booking reason / Buchungsgründe
*/
LoyaltyCardBookingReason(): __Observable<ResponseArgsOfIEnumerableOfString> {
LoyaltyCardBookingReason(): __Observable<ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger> {
return this.LoyaltyCardBookingReasonResponse().pipe(
__map(_r => _r.body as ResponseArgsOfIEnumerableOfString)
__map(_r => _r.body as ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger)
);
}

View File

@@ -0,0 +1,31 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services/crm-search.service';
import { AddBookingInput } from '../schemas';
import {
KeyValueDTOOfStringAndInteger,
KeyValueDTOOfStringAndString,
LoyaltyBookingInfoDTO,
} from '@generated/swagger/crm-api';
@Injectable({ providedIn: 'root' })
export class CustomerCardBookingFacade {
#crmSearchService = inject(CrmSearchService);
async fetchBookingReasons(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndInteger[]> {
return this.#crmSearchService.fetchBookingReasons(abortSignal);
}
async fetchCurrentBookingPartnerStore(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndString | undefined> {
return this.#crmSearchService.fetchCurrentBookingPartnerStore(abortSignal);
}
async addBooking(
params: AddBookingInput,
): Promise<LoyaltyBookingInfoDTO | undefined> {
return this.#crmSearchService.addBooking(params);
}
}

View File

@@ -1,2 +1,3 @@
export * from './customer-cards.facade';
export * from './customer.facade';
export * from './customer-card-booking.facade';

View File

@@ -0,0 +1,29 @@
import { Injectable, inject, resource } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CrmSearchService } from '@isa/crm/data-access';
import { KeyValueDTOOfStringAndInteger } from '@generated/swagger/crm-api';
@Injectable()
export class CustomerBookingReasonsResource {
readonly #crmSearchService = inject(CrmSearchService);
readonly #logger = logger(() => ({
context: 'CustomerBookingReasonsResource',
}));
readonly resource = resource({
loader: async ({
abortSignal,
}): Promise<KeyValueDTOOfStringAndInteger[] | undefined> => {
this.#logger.debug('Loading Booking Reasons');
const reasons =
await this.#crmSearchService.fetchBookingReasons(abortSignal);
this.#logger.debug('Booking Reasons loaded', () => ({
count: reasons.length,
}));
return reasons;
},
});
}

View File

@@ -15,9 +15,6 @@ import { LoyaltyBookingInfoDTO } from '@generated/swagger/crm-api';
*
* @example
* ```typescript
* @Component({
* providers: [CustomerCardTransactionsResource],
* })
* export class MyFeatureComponent {
* #transactionsResource = inject(CustomerCardTransactionsResource);
*
@@ -30,7 +27,7 @@ import { LoyaltyBookingInfoDTO } from '@generated/swagger/crm-api';
* }
* ```
*/
@Injectable()
@Injectable({ providedIn: 'root' })
export class CustomerCardTransactionsResource {
readonly #crmSearchService = inject(CrmSearchService);
readonly #logger = logger(() => ({

View File

@@ -7,3 +7,4 @@ export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';
export * from './payer.resource';
export * from './customer-booking-reasons.resource';

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const AddBookingSchema = z.object({
cardCode: z.string().describe('Unique card code identifier'),
booking: z
.object({
points: z.number().describe('Booking points'),
reason: z.string().optional().describe('Booking Reason'),
storeId: z
.string()
.optional()
.describe('Booking store (convercus store id)'),
})
.describe('Booking details'),
});
export type AddBooking = z.infer<typeof AddBookingSchema>;
export type AddBookingInput = z.input<typeof AddBookingSchema>;

View File

@@ -17,3 +17,4 @@ export * from './payer.schema';
export * from './payment-settings.schema';
export * from './shipping-address.schema';
export * from './user.schema';
export * from './add-booking.schema';

View File

@@ -3,8 +3,13 @@ import {
CustomerService,
LoyaltyCardService,
LoyaltyBookingInfoDTO,
KeyValueDTOOfStringAndString,
KeyValueDTOOfStringAndInteger,
} from '@generated/swagger/crm-api';
import {
AddBooking,
AddBookingInput,
AddBookingSchema,
Customer,
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
@@ -14,6 +19,7 @@ import {
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
@@ -104,4 +110,77 @@ export class CrmSearchService {
return [];
}
}
async fetchBookingReasons(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndInteger[]> {
this.#logger.info('Fetching booking reasons from API');
let req$ = this.#loyaltyCardService
.LoyaltyCardBookingReason()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched booking reasons');
return res?.result || [];
} catch (error) {
this.#logger.error('Error fetching booking reasons', error);
return [];
}
}
async fetchCurrentBookingPartnerStore(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndString | undefined> {
this.#logger.info('Fetching current booking partner store from API');
let req$ = this.#loyaltyCardService
.LoyaltyCardCurrentBookingPartnerStore()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched current booking partner store');
return res?.result;
} catch (error) {
this.#logger.error('Error fetching current booking partner store', error);
return undefined;
}
}
async addBooking(
params: AddBookingInput,
): Promise<LoyaltyBookingInfoDTO | undefined> {
const parsed = AddBookingSchema.parse(params);
const req$ = this.#loyaltyCardService.LoyaltyCardAddBooking({
cardCode: parsed.cardCode,
booking: {
points: parsed.booking.points,
reason: parsed.booking.reason,
storeId: parsed.booking.storeId,
},
});
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Add Booking Failed', err);
throw err;
}
return res?.result;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './lib/crm-feature-customer-booking/crm-feature-customer-booking.component';

View File

@@ -0,0 +1,15 @@
:host {
@apply h-[15.5rem] flex flex-col gap-4 rounded-2xl bg-isa-neutral-200 p-8 justify-between;
}
/* Remove number input arrows */
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}

View File

@@ -0,0 +1,67 @@
@if (cardCode() && !bookingReasonsLoading()) {
<div class="flex flex-col gap-1 text-isa-neutral-900">
<span class="isa-text-body-1-bold">Kulanzbuchungen</span>
<span class="isa-text-body-2-regular">1€ entspricht 10 Lesepunkten</span>
</div>
<div
class="grid grid-cols-[1fr,auto] items-center justify-between gap-4 border rounded-lg border-isa-neutral-900 px-4 py-1"
>
<div class="isa-text-body-1-bold">Buchen</div>
<div class="flex items-center gap-2">
<div class="flex items-center">
@if (selectedReason(); as reason) {
<span class="isa-text-body-2-bold text-isa-neutral-900 px-2">
{{ reason.value > 0 ? '+' : '-' }}
</span>
}
<input
name="points"
placeholder="Punkte"
type="number"
[ngModel]="points()"
(ngModelChange)="points.set($event)"
min="0"
data-what="input"
data-which="points"
class="w-20 isa-text-body-2-bold bg-isa-neutral-200 placeholder:isa-text-body-2-regular placeholder:text-isa-neutral-500 text-isa-neutral-900 focus:outline-none px-4 text-right border-none"
/>
</div>
<ui-dropdown
[ngModel]="selectedReasonKey()"
(ngModelChange)="selectedReasonKey.set($event)"
class="border-none w-[14rem] truncate"
[label]="dropdownLabel()"
data-what="dropdown"
data-which="booking-reason"
>
@if (bookingReasons(); as reasons) {
@for (reason of reasons; track reason.key) {
<ui-dropdown-option
[value]="reason.label"
data-what="dropdown-option"
data-which="reason-option"
[attr.data-reason-key]="reason.key"
>
{{ reason.label }}
</ui-dropdown-option>
}
}
</ui-dropdown>
</div>
</div>
<button
class="w-40"
uiButton
type="button"
color="primary"
(click)="booking()"
[disabled]="disableBooking()"
[pending]="isBooking()"
data-what="button"
data-which="booking-submit"
>
Jetzt buchen
</button>
}

View File

@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CrmFeatureCustomerBookingComponent } from './crm-feature-customer-booking.component';
describe('CrmFeatureCustomerBookingComponent', () => {
let component: CrmFeatureCustomerBookingComponent;
let fixture: ComponentFixture<CrmFeatureCustomerBookingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CrmFeatureCustomerBookingComponent],
}).compileComponents();
fixture = TestBed.createComponent(CrmFeatureCustomerBookingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,149 @@
import {
ChangeDetectionStrategy,
Component,
signal,
computed,
input,
inject,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
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';
@Component({
selector: 'crm-customer-booking',
imports: [
FormsModule,
ButtonComponent,
DropdownButtonComponent,
DropdownOptionComponent,
],
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);
#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() {
// Timeout to ensure that the new booking is available in the transaction history
setTimeout(() => {
this.#transactionResource.params({ cardCode: this.cardCode() });
this.#transactionResource.resource.reload();
}, 500);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
<div class="flex flex-col gap-[24px] px-4 overflow-hidden overflow-y-scroll">
<h2 class="isa-text-body-1-bold text-isa-neutral-900">
Letzte Transaktionen des Kunden
</h2>
<div class="flex flex-col gap-[24px] px-4">
@if (transactions()?.length) {
<h2 class="isa-text-body-1-bold text-isa-neutral-900">
Letzte 5 Transaktionen des Kunden
</h2>
}
@if (isLoading()) {
<div class="text-isa-neutral-500 text-sm">Lade Transaktionen...</div>
@@ -11,9 +13,10 @@
</div>
} @else if (!transactions()?.length) {
<ui-empty-state
class="self-center"
title="Keine Transaktionen"
description="Für diese Kundenkarte wurden noch keine Transaktionen erfasst"
appearance="no-results"
appearance="noResults"
/>
} @else {
<table cdk-table [dataSource]="dataSource()" [trackBy]="trackByDate">

View File

@@ -24,10 +24,7 @@ import { LoyaltyBookingInfoDTO } from '@generated/swagger/crm-api';
NgIconComponent,
EmptyStateComponent,
],
providers: [
CustomerCardTransactionsResource,
provideIcons({ isaActionPolygonUp, isaActionPolygonDown }),
],
providers: [provideIcons({ isaActionPolygonUp, isaActionPolygonDown })],
templateUrl: './crm-feature-customer-card-transactions.component.html',
styleUrl: './crm-feature-customer-card-transactions.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -78,6 +75,8 @@ export class CrmFeatureCustomerCardTransactionsComponent {
const code = this.cardCode();
this.#logger.debug('Card code changed', () => ({ cardCode: code }));
this.#transactionsResource.params({ cardCode: code });
console.log(this.transactions());
});
}
}

76920
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -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-booking": [
"libs/crm/feature/customer-booking/src/index.ts"
],
"@isa/crm/feature/customer-card-transactions": [
"libs/crm/feature/customer-card-transactions/src/index.ts"
],