From 36ca61cbcea07f6247f491b0f3b70b8f5fcb1d09 Mon Sep 17 00:00:00 2001 From: Stefan Hauke Date: Wed, 12 Jun 2024 09:18:59 +0200 Subject: [PATCH 1/8] feat: introduce Formly number field (quantity input with + and - buttons) --- docs/guides/formly.md | 37 +++++++++-------- .../number-field/number-field.component.html | 35 ++++++++++++++++ .../number-field/number-field.component.scss | 31 ++++++++++++++ .../number-field/number-field.component.ts | 41 +++++++++++++++++++ src/app/shared/formly/types/types.module.ts | 7 ++++ src/assets/i18n/de_DE.json | 2 + src/assets/i18n/en_US.json | 2 + src/assets/i18n/fr_FR.json | 2 + 8 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 src/app/shared/formly/types/number-field/number-field.component.html create mode 100644 src/app/shared/formly/types/number-field/number-field.component.scss create mode 100644 src/app/shared/formly/types/number-field/number-field.component.ts diff --git a/docs/guides/formly.md b/docs/guides/formly.md index 46b5fef81e..7de22ef7c0 100644 --- a/docs/guides/formly.md +++ b/docs/guides/formly.md @@ -61,7 +61,7 @@ A configuration for a form containing only a basic input field could be defined ```typescript const fields: FormlyFieldConfig[] = [ { - type: 'ish-input-field', + type: 'ish-text-input-field', key: 'example-input', props: { required: true, @@ -254,23 +254,24 @@ Refer to the tables below for an overview of these parts. - Template option `inputClass`: These CSS class(es) will be added to all input/select/textarea/text tags. - Template option `ariaLabel`: Adds an aria-label to all input/select/textarea tags. -| Name | Description | Relevant props | -| --------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ish-text-input-field | Basic input field, supports all text types | `type`: 'text (default),'email','tel','password'. `mask`: input mask for a needed pattern (see [ngx-mask](https://www.npmjs.com/package/ngx-mask) for more information) | -| ish-select-field | Basic select field | `options`: `{ value: any; label: string}[]` or Observable. `placeholder`: Translation key or string for the default selection. `optionsTranslateDisabled`: Disables options label translation (placeholder is still translated). | -| ish-textarea-field | Basic textarea field | `cols` & `rows`: Specifies the dimensions of the textarea | -| ish-checkbox-field | Basic checkbox input | `title`: Title for a checkbox | -| ish-email-field | Email input field that automatically adds an e-mail validator and error messages | ---- | -| ish-password-field | Password input field that automatically adds a password validator and error messages | ---- | -| ish-phone-field | Phone number input field that automatically adds a phone number validator and error messages | ---- | -| ish-fieldset-field | Wraps fields in a `
` tag for styling | `fieldsetClass`: Class that will be added to the fieldset tag. `childClass`: Class that will be added to the child div. `legend`: Legend element that will be added to the fieldset, use the value as the legend text. `legendClass`: Class that will be added to the legend tag. | -| ish-captcha-field | Includes the `` component and adds the relevant `formControls` to the form | `topic`: Topic that will be passed to the Captcha component. | -| ish-radio-field | Basic radio input | ---- | -| ish-radio-group-field | Radio button group inline for price type selection | `opts`: Array of label/value pairs | -| ish-plain-text-field | Only display the form value | ---- | -| ish-html-text-field | Only display the form value as html | ---- | -| ish-date-picker-field | Basic datepicker | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `isSatExcluded`: Specifies if saturdays can be disabled. `isSunExcluded`: Specifies if sundays can be disabled. | -| ish-date-range-picker-field | Datepicker with range | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `startDate`: The start date. `placeholder`: Placeholder that displays the date format in the input field. | +| Name | Description | Relevant props | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ish-text-input-field | Basic input field, supports all text types | `type`: 'text (default),'email','tel','password'. `mask`: input mask for a needed pattern (see [ngx-mask](https://www.npmjs.com/package/ngx-mask) for more information) | +| ish-select-field | Basic select field | `options`: `{ value: any; label: string}[]` or Observable. `placeholder`: Translation key or string for the default selection. `optionsTranslateDisabled`: Disables options label translation (placeholder is still translated). | +| ish-textarea-field | Basic textarea field | `cols` & `rows`: Specifies the dimensions of the textarea | +| ish-checkbox-field | Basic checkbox input | `title`: Title for a checkbox | +| ish-email-field | Email input field that automatically adds an e-mail validator and error messages | ---- | +| ish-password-field | Password input field that automatically adds a password validator and error messages | ---- | +| ish-phone-field | Phone number input field that automatically adds a phone number validator and error messages | ---- | +| ish-fieldset-field | Wraps fields in a `
` tag for styling | `fieldsetClass`: Class that will be added to the fieldset tag. `childClass`: Class that will be added to the child div. `legend`: Legend element that will be added to the fieldset, use the value as the legend text. `legendClass`: Class that will be added to the legend tag. | +| ish-captcha-field | Includes the `` component and adds the relevant `formControls` to the form | `topic`: Topic that will be passed to the Captcha component. | +| ish-radio-field | Basic radio input | ---- | +| ish-radio-group-field | Radio button group inline for price type selection | `opts`: Array of label/value pairs | +| ish-plain-text-field | Only display the form value | ---- | +| ish-html-text-field | Only display the form value as html | ---- | +| ish-date-picker-field | Basic datepicker | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `isSatExcluded`: Specifies if saturdays can be disabled. `isSunExcluded`: Specifies if sundays can be disabled. | +| ish-date-range-picker-field | Datepicker with range | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `startDate`: The start date. `placeholder`: Placeholder that displays the date format in the input field. | +| ish-number-field | Basic number input field for smaller Integer numbers, with `+` and `-` buttons (use `ish-text-input-field` with `mask` for larger numbers) | `min`, `max` and `step` input configuration is considered by the in-/decrease buttons | ### Wrappers diff --git a/src/app/shared/formly/types/number-field/number-field.component.html b/src/app/shared/formly/types/number-field/number-field.component.html new file mode 100644 index 0000000000..496b0c27c3 --- /dev/null +++ b/src/app/shared/formly/types/number-field/number-field.component.html @@ -0,0 +1,35 @@ +
+ + + +
diff --git a/src/app/shared/formly/types/number-field/number-field.component.scss b/src/app/shared/formly/types/number-field/number-field.component.scss new file mode 100644 index 0000000000..97d347ebca --- /dev/null +++ b/src/app/shared/formly/types/number-field/number-field.component.scss @@ -0,0 +1,31 @@ +.counter-input { + position: relative; + + button { + position: absolute; + width: auto; + margin: 0; + } + + .decrease-button { + left: 0; + } + + .increase-button { + right: 0; + } + + // https://www.w3schools.com/howto/howto_css_hide_arrow_number.asp + + /* Chrome, Safari, Edge, Opera */ + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + margin: 0; + appearance: none; + } + + /* Firefox */ + input[type='number'] { + appearance: textfield; + } +} diff --git a/src/app/shared/formly/types/number-field/number-field.component.ts b/src/app/shared/formly/types/number-field/number-field.component.ts new file mode 100644 index 0000000000..d13d2df3a6 --- /dev/null +++ b/src/app/shared/formly/types/number-field/number-field.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FieldType, FieldTypeConfig } from '@ngx-formly/core'; + +/** + * Type for a number field + * + * @defaultWrappers form-field-horizontal & validation + */ +@Component({ + selector: 'ish-number-field', + templateUrl: './number-field.component.html', + styleUrls: ['./number-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NumberFieldComponent extends FieldType implements OnInit { + cannotIncrease = false; + cannotDecrease = false; + + ngOnInit(): void { + this.evaluateButtonDisabled(); + } + + increase() { + this.formControl.setValue( + Number.parseInt(this.formControl.value) + (this.field.props.step ? this.field.props.step : 1) + ); + this.evaluateButtonDisabled(); + } + + decrease() { + this.formControl.setValue( + Number.parseInt(this.formControl.value) - (this.field.props.step ? this.field.props.step : 1) + ); + this.evaluateButtonDisabled(); + } + + private evaluateButtonDisabled() { + this.cannotDecrease = this.field.props.min && this.formControl.value <= this.field.props.min; + this.cannotIncrease = this.field.props.max && this.formControl.value >= this.field.props.max; + } +} diff --git a/src/app/shared/formly/types/types.module.ts b/src/app/shared/formly/types/types.module.ts index 15d5b68a62..7c945f31bd 100644 --- a/src/app/shared/formly/types/types.module.ts +++ b/src/app/shared/formly/types/types.module.ts @@ -26,6 +26,7 @@ import { LocalizedParserFormatter } from './date-picker-field/localized-parser-f import { DateRangePickerFieldComponent } from './date-range-picker-field/date-range-picker-field.component'; import { FieldsetFieldComponent } from './fieldset-field/fieldset-field.component'; import { HtmlTextFieldComponent } from './html-text-field/html-text-field.component'; +import { NumberFieldComponent } from './number-field/number-field.component'; import { PlainTextFieldComponent } from './plain-text-field/plain-text-field.component'; import { RadioFieldComponent } from './radio-field/radio-field.component'; import { RadioGroupFieldComponent } from './radio-group-field/radio-group-field.component'; @@ -46,6 +47,7 @@ const fieldComponents = [ SelectFieldComponent, TextareaFieldComponent, TextInputFieldComponent, + NumberFieldComponent, ]; @NgModule({ @@ -173,6 +175,11 @@ const fieldComponents = [ component: DateRangePickerFieldComponent, wrappers: ['form-field-horizontal', 'validation'], }, + { + name: 'ish-number-field', + component: NumberFieldComponent, + wrappers: ['form-field-horizontal', 'validation'], + }, ], }), ], diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index cabaa91284..5e79253df0 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -958,6 +958,8 @@ "navigation.paging.go_to_page.label": "Zu Seite {{0}} gehen", "navigation.paging.next_page.label": "Zur nächsten Seite gehen", "navigation.paging.previous_page.label": "Zur vorherigen Seite gehen", + "number.decrease.text": "-", + "number.increase.text": "+", "order.tracking.error": "Leider konnte keine Bestellung mit Ihren Daten gefunden werden.", "order_template.create.heading": "Bestellvorlage anlegen", "payment.error.PaymentInstrumentAlreadyExists": "Das Zahlungsmittel konnte nicht angelegt werden. Zahlungsdaten mit den angegebenen Parametern sind bereits vorhanden.", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 539eacbbb2..62692574ee 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -958,6 +958,8 @@ "navigation.paging.go_to_page.label": "Go to page {{0}}", "navigation.paging.next_page.label": "Go to next page", "navigation.paging.previous_page.label": "Go to previous page", + "number.decrease.text": "-", + "number.increase.text": "+", "order.tracking.error": "Unfortunately, we could not locate an order with the information you provided.", "order_template.create.heading": "Create order template", "payment.error.PaymentInstrumentAlreadyExists": "The payment instrument could not be created. Payment data with the given parameters already exists.", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index 35ca67bf76..1894a72979 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -958,6 +958,8 @@ "navigation.paging.go_to_page.label": "Aller à la page {{0}}", "navigation.paging.next_page.label": "Aller à la page suivante", "navigation.paging.previous_page.label": "Aller à la page précédente", + "number.decrease.text": "-", + "number.increase.text": "+", "order.tracking.error": "Malheureusement, nous n’avons pas pu localiser une commande avec les informations que vous avez fournies.", "order_template.create.heading": "Créer un modèle de commande", "payment.error.PaymentInstrumentAlreadyExists": "Le moyen de paiement n’a pas pu être mis en place. Les données de paiement avec les paramètres fournis existent déjà.", From 2cba66944741d8e05fcf5c24cfee97cb021166cc Mon Sep 17 00:00:00 2001 From: Stefan Hauke Date: Mon, 24 Jun 2024 22:37:54 +0200 Subject: [PATCH 2/8] feat: introduce Formly information field for rendering any freestyle text within generated forms --- docs/guides/formly.md | 1 + .../information-field.component.html | 4 ++++ .../information-field.component.ts | 13 +++++++++++++ src/app/shared/formly/types/types.module.ts | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 src/app/shared/formly/types/information-field/information-field.component.html create mode 100644 src/app/shared/formly/types/information-field/information-field.component.ts diff --git a/docs/guides/formly.md b/docs/guides/formly.md index 7de22ef7c0..a114aa95ae 100644 --- a/docs/guides/formly.md +++ b/docs/guides/formly.md @@ -272,6 +272,7 @@ Refer to the tables below for an overview of these parts. | ish-date-picker-field | Basic datepicker | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `isSatExcluded`: Specifies if saturdays can be disabled. `isSunExcluded`: Specifies if sundays can be disabled. | | ish-date-range-picker-field | Datepicker with range | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `startDate`: The start date. `placeholder`: Placeholder that displays the date format in the input field. | | ish-number-field | Basic number input field for smaller Integer numbers, with `+` and `-` buttons (use `ish-text-input-field` with `mask` for larger numbers) | `min`, `max` and `step` input configuration is considered by the in-/decrease buttons | +| ish-information-field | Include any freestyle text within a Formly generated form (HTML content is supported) | provide text via `localizationKey` or just plain `text` and adapt the styling via `containerClass` | ### Wrappers diff --git a/src/app/shared/formly/types/information-field/information-field.component.html b/src/app/shared/formly/types/information-field/information-field.component.html new file mode 100644 index 0000000000..6bae178c14 --- /dev/null +++ b/src/app/shared/formly/types/information-field/information-field.component.html @@ -0,0 +1,4 @@ +
diff --git a/src/app/shared/formly/types/information-field/information-field.component.ts b/src/app/shared/formly/types/information-field/information-field.component.ts new file mode 100644 index 0000000000..269d112779 --- /dev/null +++ b/src/app/shared/formly/types/information-field/information-field.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FieldType, FieldTypeConfig } from '@ngx-formly/core'; + +/** + * Include any freestyle text within a Formly generated form (HTML content is supported). + * Provide text via `localizationKey` or just plain `text` and adapt the styling via `containerClass`. + */ +@Component({ + selector: 'ish-information-field', + templateUrl: './information-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InformationFieldComponent extends FieldType {} diff --git a/src/app/shared/formly/types/types.module.ts b/src/app/shared/formly/types/types.module.ts index 7c945f31bd..0847132500 100644 --- a/src/app/shared/formly/types/types.module.ts +++ b/src/app/shared/formly/types/types.module.ts @@ -26,6 +26,7 @@ import { LocalizedParserFormatter } from './date-picker-field/localized-parser-f import { DateRangePickerFieldComponent } from './date-range-picker-field/date-range-picker-field.component'; import { FieldsetFieldComponent } from './fieldset-field/fieldset-field.component'; import { HtmlTextFieldComponent } from './html-text-field/html-text-field.component'; +import { InformationFieldComponent } from './information-field/information-field.component'; import { NumberFieldComponent } from './number-field/number-field.component'; import { PlainTextFieldComponent } from './plain-text-field/plain-text-field.component'; import { RadioFieldComponent } from './radio-field/radio-field.component'; @@ -48,6 +49,7 @@ const fieldComponents = [ TextareaFieldComponent, TextInputFieldComponent, NumberFieldComponent, + InformationFieldComponent, ]; @NgModule({ @@ -180,6 +182,10 @@ const fieldComponents = [ component: NumberFieldComponent, wrappers: ['form-field-horizontal', 'validation'], }, + { + name: 'ish-information-field', + component: InformationFieldComponent, + }, ], }), ], From 366d63984b2d4611abac7131defd5ff087a6bf27 Mon Sep 17 00:00:00 2001 From: Susanne Schneider Date: Thu, 26 Sep 2024 20:17:50 +0200 Subject: [PATCH 3/8] feat: introduce Switch component (UI element for imidiate status changes) * implemented as standalone component --- .../common/switch/switch.component.html | 13 ++++++ .../common/switch/switch.component.scss | 23 ++++++++++ .../common/switch/switch.component.spec.ts | 25 +++++++++++ .../common/switch/switch.component.ts | 42 +++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 src/app/shared/components/common/switch/switch.component.html create mode 100644 src/app/shared/components/common/switch/switch.component.scss create mode 100644 src/app/shared/components/common/switch/switch.component.spec.ts create mode 100644 src/app/shared/components/common/switch/switch.component.ts diff --git a/src/app/shared/components/common/switch/switch.component.html b/src/app/shared/components/common/switch/switch.component.html new file mode 100644 index 0000000000..d44a6b3945 --- /dev/null +++ b/src/app/shared/components/common/switch/switch.component.html @@ -0,0 +1,13 @@ + + + + diff --git a/src/app/shared/components/common/switch/switch.component.scss b/src/app/shared/components/common/switch/switch.component.scss new file mode 100644 index 0000000000..fb76d6d7d9 --- /dev/null +++ b/src/app/shared/components/common/switch/switch.component.scss @@ -0,0 +1,23 @@ +@import 'variables'; + +.custom-control-input ~ .custom-control-label::before { + background-color: $text-color-quaternary; + border-color: $text-color-quaternary; +} + +.custom-control-input:disabled ~ .custom-control-label::before { + background-color: $text-color-quinary; +} + +.custom-control-input:checked ~ .custom-control-label::before { + background-color: $CORPORATE-PRIMARY; + border-color: $CORPORATE-PRIMARY; +} + +.custom-control-input:checked:disabled ~ .custom-control-label::before { + background-color: $CORPORATE-LIGHT; +} + +.custom-switch .custom-control-label::after { + background-color: $white; +} diff --git a/src/app/shared/components/common/switch/switch.component.spec.ts b/src/app/shared/components/common/switch/switch.component.spec.ts new file mode 100644 index 0000000000..4b20ec3e72 --- /dev/null +++ b/src/app/shared/components/common/switch/switch.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SwitchComponent } from './switch.component'; + +describe('Switch Component', () => { + let component: SwitchComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({}).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SwitchComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/shared/components/common/switch/switch.component.ts b/src/app/shared/components/common/switch/switch.component.ts new file mode 100644 index 0000000000..93d72e4909 --- /dev/null +++ b/src/app/shared/components/common/switch/switch.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { v4 as uuid } from 'uuid'; + +@Component({ + selector: 'ish-switch', + templateUrl: './switch.component.html', + styleUrls: ['./switch.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, +}) +/** + * The Switch Component is a reusable component that allows toggling between two states for a given object. + * The bootstrap switch component is used to render the switch. + * + * @example + * + */ +export class SwitchComponent implements OnChanges { + // id is not required but can be used to identify the switch context + @Input() id: string = uuid(); + @Input() active = false; + @Input() labelActive = ''; + @Input() labelInactive = ''; + @Input() disabled = false; + + @Output() toggleSwitch = new EventEmitter<{ active: boolean; id: string }>(); + + activeState: boolean; + + ngOnChanges() { + this.activeState = this.active; + } + + toggleState() { + this.activeState = !this.activeState; + this.toggleSwitch.emit({ active: this.activeState, id: this.id }); + } +} From dc4f4df5f8447308ae8d39a0b335960d856d3ba2 Mon Sep 17 00:00:00 2001 From: Stefan Hauke Date: Thu, 26 Sep 2024 20:21:41 +0200 Subject: [PATCH 4/8] feat: introduce Server Setting route guard * checks for an enabled server setting before routing --- src/app/core/guards/server-setting.guard.ts | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/app/core/guards/server-setting.guard.ts diff --git a/src/app/core/guards/server-setting.guard.ts b/src/app/core/guards/server-setting.guard.ts new file mode 100644 index 0000000000..6ee70eea06 --- /dev/null +++ b/src/app/core/guards/server-setting.guard.ts @@ -0,0 +1,30 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { map } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { HttpStatusCodeService } from 'ish-core/utils/http-status-code/http-status-code.service'; + +/** + * Routes only to the page if the configured server setting at the route is enabled + */ +export function serverSettingGuard(route: ActivatedRouteSnapshot) { + const appFacade = inject(AppFacade); + const router = inject(Router); + const httpStatusCodeService = inject(HttpStatusCodeService); + + return appFacade.serverSetting$(route.data.serverSetting).pipe( + map(enabled => { + if (!enabled) { + httpStatusCodeService.setStatus(404, false); + return router.createUrlTree(['/error'], { + queryParams: { + error: 'server-setting-deactivated', + value: route.data.serverSetting, + }, + }); + } + return true; + }) + ); +} From 74b600e43f95b1086addc7bc41bae6dc8fd43de7 Mon Sep 17 00:00:00 2001 From: Stefan Hauke Date: Thu, 26 Sep 2024 20:23:17 +0200 Subject: [PATCH 5/8] feat: order recurrence (subscription support) * create recurring order during checkout * handle recurring orders in requisition/approval listings * recurring orders listing in my account * recurring order details Co-authored-by: Susanne Schneider --- ...heckout-receipt-requisition.component.html | 2 +- .../requisitions-list.component.html | 14 +- .../requisition/requisition.interface.ts | 1 + .../requisition/requisition.mapper.spec.ts | 2 + .../models/requisition/requisition.mapper.ts | 3 +- .../models/requisition/requisition.model.ts | 1 + .../requisition-buyer-approval.component.html | 8 +- ...sition-cost-center-approval.component.html | 12 +- .../requisition-summary.component.html | 18 +- src/app/core/facades/account.facade.ts | 32 ++- src/app/core/facades/checkout.facade.ts | 25 +- src/app/core/icon.module.ts | 2 + .../core/models/basket/basket.interface.ts | 2 + src/app/core/models/basket/basket.mapper.ts | 1 + src/app/core/models/basket/basket.model.ts | 2 + .../breadcrumb-item.interface.ts | 1 + src/app/core/models/order/order.interface.ts | 3 +- src/app/core/models/order/order.mapper.ts | 1 + src/app/core/models/order/order.model.ts | 3 +- src/app/core/models/price/price.interface.ts | 5 + .../models/recurrence/recurrence.model.ts | 6 + .../recurring-order.interface.ts | 69 +++++ .../recurring-order.mapper.spec.ts | 31 +++ .../recurring-order/recurring-order.mapper.ts | 126 +++++++++ .../recurring-order/recurring-order.model.ts | 31 +++ src/app/core/pipes.module.ts | 2 + src/app/core/pipes/frequency.pipe.ts | 35 +++ .../core/services/basket/basket.service.ts | 4 +- .../recurring-orders.service.spec.ts | 28 ++ .../recurring-orders.service.ts | 104 +++++++ .../store/customer/basket/basket.actions.ts | 7 + .../store/customer/basket/basket.effects.ts | 12 + .../store/customer/customer-store.module.ts | 4 + .../store/customer/customer-store.spec.ts | 2 + src/app/core/store/customer/customer-store.ts | 2 + .../store/customer/orders/orders.actions.ts | 5 +- .../customer/orders/orders.effects.spec.ts | 8 +- .../store/customer/orders/orders.effects.ts | 13 +- .../store/customer/orders/orders.reducer.ts | 10 +- .../store/customer/recurring-orders/index.ts | 3 + .../recurring-orders.actions.ts | 28 ++ .../recurring-orders.effects.spec.ts | 50 ++++ .../recurring-orders.effects.ts | 134 ++++++++++ .../recurring-orders.reducer.ts | 59 ++++ .../recurring-orders.selectors.spec.ts | 41 +++ .../recurring-orders.selectors.ts | 32 +++ .../account-order.component.html | 7 + ...ccount-recurring-order-page.component.html | 200 ++++++++++++++ ...unt-recurring-order-page.component.spec.ts | 64 +++++ .../account-recurring-order-page.component.ts | 51 ++++ .../account-recurring-order-page.module.ts | 15 ++ ...count-recurring-orders-page.component.html | 28 ++ ...nt-recurring-orders-page.component.spec.ts | 55 ++++ ...account-recurring-orders-page.component.ts | 55 ++++ .../account-recurring-orders-page.module.ts | 26 ++ .../recurring-order-list.component.html | 158 +++++++++++ .../recurring-order-list.component.spec.ts | 37 +++ .../recurring-order-list.component.ts | 47 ++++ .../account-navigation.items.b2c.ts | 6 + .../account-navigation.items.ts | 6 + src/app/pages/account/account-page.module.ts | 13 + ...asket-order-recurrence-edit.component.html | 35 +++ ...asket-order-recurrence-edit.component.scss | 12 + ...et-order-recurrence-edit.component.spec.ts | 33 +++ .../basket-order-recurrence-edit.component.ts | 253 ++++++++++++++++++ src/app/pages/basket/basket-page.module.ts | 2 + .../shopping-basket-payment.component.html | 35 +-- .../shopping-basket.component.html | 5 + .../shopping-basket.component.spec.ts | 4 +- .../checkout-address.component.html | 2 + .../checkout-address.component.spec.ts | 2 + .../checkout-payment.component.html | 3 + .../checkout-payment.component.spec.ts | 2 + .../checkout-receipt-order.component.html | 22 +- .../checkout-receipt-order.component.ts | 3 +- .../checkout-receipt-page.component.ts | 5 +- .../checkout-receipt.component.html | 21 +- .../checkout-receipt.component.ts | 3 +- .../checkout-review.component.html | 40 ++- .../checkout-shipping-page.component.html | 4 + .../checkout-shipping-page.component.spec.ts | 2 + .../basket-approval-info.component.html | 1 + ...asket-desired-delivery-date.component.html | 36 +-- ...et-desired-delivery-date.component.spec.ts | 17 +- .../basket-recurrence-summary.component.html | 5 + .../basket-recurrence-summary.component.scss | 5 + ...asket-recurrence-summary.component.spec.ts | 27 ++ .../basket-recurrence-summary.component.ts | 13 + .../breadcrumb/breadcrumb.component.html | 10 +- .../order-list/order-list.component.html | 6 + .../order-recurrence.component.html | 16 ++ .../order-recurrence.component.scss | 9 + .../order-recurrence.component.spec.ts | 27 ++ .../order-recurrence.component.ts | 15 ++ src/app/shared/shared.module.ts | 4 + src/assets/i18n/de_DE.json | 82 +++++- src/assets/i18n/en_US.json | 84 +++++- src/assets/i18n/fr_FR.json | 82 +++++- src/styles/global/global.scss | 4 + src/styles/pages/checkout/shopping-cart.scss | 5 - 100 files changed, 2576 insertions(+), 112 deletions(-) create mode 100644 src/app/core/models/recurrence/recurrence.model.ts create mode 100644 src/app/core/models/recurring-order/recurring-order.interface.ts create mode 100644 src/app/core/models/recurring-order/recurring-order.mapper.spec.ts create mode 100644 src/app/core/models/recurring-order/recurring-order.mapper.ts create mode 100644 src/app/core/models/recurring-order/recurring-order.model.ts create mode 100644 src/app/core/pipes/frequency.pipe.ts create mode 100644 src/app/core/services/recurring-orders/recurring-orders.service.spec.ts create mode 100644 src/app/core/services/recurring-orders/recurring-orders.service.ts create mode 100644 src/app/core/store/customer/recurring-orders/index.ts create mode 100644 src/app/core/store/customer/recurring-orders/recurring-orders.actions.ts create mode 100644 src/app/core/store/customer/recurring-orders/recurring-orders.effects.spec.ts create mode 100644 src/app/core/store/customer/recurring-orders/recurring-orders.effects.ts create mode 100644 src/app/core/store/customer/recurring-orders/recurring-orders.reducer.ts create mode 100644 src/app/core/store/customer/recurring-orders/recurring-orders.selectors.spec.ts create mode 100644 src/app/core/store/customer/recurring-orders/recurring-orders.selectors.ts create mode 100644 src/app/pages/account-recurring-order/account-recurring-order-page.component.html create mode 100644 src/app/pages/account-recurring-order/account-recurring-order-page.component.spec.ts create mode 100644 src/app/pages/account-recurring-order/account-recurring-order-page.component.ts create mode 100644 src/app/pages/account-recurring-order/account-recurring-order-page.module.ts create mode 100644 src/app/pages/account-recurring-orders/account-recurring-orders-page.component.html create mode 100644 src/app/pages/account-recurring-orders/account-recurring-orders-page.component.spec.ts create mode 100644 src/app/pages/account-recurring-orders/account-recurring-orders-page.component.ts create mode 100644 src/app/pages/account-recurring-orders/account-recurring-orders-page.module.ts create mode 100644 src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.html create mode 100644 src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.spec.ts create mode 100644 src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.ts create mode 100644 src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.html create mode 100644 src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.scss create mode 100644 src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.spec.ts create mode 100644 src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.ts create mode 100644 src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.html create mode 100644 src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.scss create mode 100644 src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.spec.ts create mode 100644 src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.ts create mode 100644 src/app/shared/components/order/order-recurrence/order-recurrence.component.html create mode 100644 src/app/shared/components/order/order-recurrence/order-recurrence.component.scss create mode 100644 src/app/shared/components/order/order-recurrence/order-recurrence.component.spec.ts create mode 100644 src/app/shared/components/order/order-recurrence/order-recurrence.component.ts diff --git a/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.html b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.html index 5d7d9c382b..68236bebdb 100644 --- a/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.html +++ b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.html @@ -10,7 +10,7 @@

{{ req.requisitionNo }}{{ req.requisitionNo || req.recurringOrderDocumentNo }}

diff --git a/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.html b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.html index 77aa060eac..199c965f8a 100644 --- a/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.html +++ b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.html @@ -19,8 +19,14 @@ [routerLink]="[requisition.id, { status: status }]" [attr.aria-label]="'account.approvallist.table.id_of_order.aria_label' | translate" > - {{ requisition.requisitionNo }} + {{ requisition.requisitionNo || requisition.recurringOrderDocumentNo }} + @@ -34,7 +40,9 @@ *cdkCellDef="let requisition" [attr.data-label]="'account.approvallist.table.no_of_order' | translate" > - {{ requisition.orderNo }} + + {{ requisition.orderNo || requisition.recurringOrderDocumentNo }} + @@ -48,7 +56,7 @@ *cdkCellDef="let requisition" [attr.data-label]="'account.approvallist.table.no_of_order' | translate" > - {{ requisition.orderNo }} + {{ requisition.orderNo || requisition.recurringOrderDocumentNo }} diff --git a/projects/requisition-management/src/app/models/requisition/requisition.interface.ts b/projects/requisition-management/src/app/models/requisition/requisition.interface.ts index 938471e8b5..015dc07a2a 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.interface.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.interface.ts @@ -9,6 +9,7 @@ import { RequisitionApproval, RequisitionUserBudget } from './requisition.model' export interface RequisitionBaseData extends BasketBaseData { requisitionNo: string; orderNo?: string; + recurringOrderDocumentNo?: string; order?: { itemId: string; }; diff --git a/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts index a30c4893bc..063ac4d79c 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts @@ -111,6 +111,8 @@ describe('Requisition Mapper', () => { "payment": undefined, "promotionCodes": undefined, "purchaseCurrency": "USD", + "recurrence": undefined, + "recurringOrderDocumentNo": undefined, "requisitionNo": "0001", "systemRejectErrors": [ "some message", diff --git a/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts b/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts index bae189a477..7163693e1f 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts @@ -24,6 +24,7 @@ export class RequisitionMapper { id: data.id, requisitionNo: data.requisitionNo, orderNo: data.orderNo, + recurringOrderDocumentNo: data.recurringOrderDocumentNo, creationDate: data.creationDate, userBudget: this.fromUserBudgets(data.userBudgets, data.purchaseCurrency), lineItemCount: data.lineItemCount, @@ -64,7 +65,7 @@ export class RequisitionMapper { return ( payload.data /* filter requisitions that didn't need an approval */ - .filter(data => data.requisitionNo) + .filter(data => data.requisitionNo || data.recurringOrderDocumentNo) .map(data => ({ ...this.fromData({ ...payload, data }), totals: { diff --git a/projects/requisition-management/src/app/models/requisition/requisition.model.ts b/projects/requisition-management/src/app/models/requisition/requisition.model.ts index 8cbf85b392..04b50b4b24 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.model.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.model.ts @@ -39,6 +39,7 @@ type RequisitionBasket = Omit, 'approval'>; export interface Requisition extends RequisitionBasket { requisitionNo: string; orderNo?: string; + recurringOrderDocumentNo?: string; creationDate: number; lineItemCount: number; diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-buyer-approval/requisition-buyer-approval.component.html b/projects/requisition-management/src/app/pages/requisition-detail/requisition-buyer-approval/requisition-buyer-approval.component.html index bc7a32622c..7a27dacd04 100644 --- a/projects/requisition-management/src/app/pages/requisition-detail/requisition-buyer-approval/requisition-buyer-approval.component.html +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-buyer-approval/requisition-buyer-approval.component.html @@ -59,7 +59,8 @@

*ngIf="requisition.approval.statusCode !== 'APPROVED'; else leftBudgetDisplay" class="row dl-horizontal dl-separator" > -
{{ 'approval.detailspage.budget.including_order.label' | translate }}
+ +
{{ 'approval.detailspage.budget.including_order.label' | translate : { recurring: !requisition.requisitionNo } }}
{{ requisition.userBudget?.spentBudgetIncludingThisRequisition || requisition.totals.total @@ -87,5 +88,10 @@

/> +
+
+ +
+
diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html index 181dd76046..7482512d5a 100644 --- a/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html @@ -39,7 +39,8 @@

-
{{ 'approval.detailspage.budget.including_order.label' | translate }}
+ +
{{ 'approval.detailspage.budget.including_order.label' | translate : { recurring: !requisition.requisitionNo } }}
{{ ccVal.spentBudgetIncludingThisRequisition | ishPrice }} ({{ ccVal.spentPercentageIncludingThisRequisition | percent @@ -79,7 +80,8 @@

-
{{ 'approval.detailspage.budget.including_order.label' | translate }}
+ +
{{ 'approval.detailspage.budget.including_order.label' | translate : { recurring: !requisition.requisitionNo } }}
{{ bVal.spentBudgetIncludingThisRequisition | ishPrice }} ({{ bVal.spentPercentageIncludingThisRequisition | percent @@ -91,6 +93,12 @@

+ +
+
+ +
+
diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-summary/requisition-summary.component.html b/projects/requisition-management/src/app/pages/requisition-detail/requisition-summary/requisition-summary.component.html index 9690bb667e..b199f74070 100644 --- a/projects/requisition-management/src/app/pages/requisition-detail/requisition-summary/requisition-summary.component.html +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-summary/requisition-summary.component.html @@ -1,15 +1,21 @@
{{ 'approval.detailspage.order.request_id' | translate }}
-
{{ requisition.requisitionNo }}
+
{{ requisition.requisitionNo || requisition.recurringOrderDocumentNo }}
-
{{ 'approval.detailspage.order_reference_id.label' | translate }}
+ +
{{ 'approval.detailspage.order_reference_id.label' | translate : { recurring: !requisition.requisitionNo } }}
- {{ - requisition.orderNo - }} - {{ requisition.orderNo }} + + {{ requisition.orderNo || requisition.recurringOrderDocumentNo }} + + + {{ requisition.orderNo || requisition.recurringOrderDocumentNo }} +
diff --git a/src/app/core/facades/account.facade.ts b/src/app/core/facades/account.facade.ts index 9e009c2912..5165e54754 100644 --- a/src/app/core/facades/account.facade.ts +++ b/src/app/core/facades/account.facade.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; import { Observable, Subject, of } from 'rxjs'; -import { map, switchMap, take, tap } from 'rxjs/operators'; +import { distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators'; import { Address } from 'ish-core/models/address/address.model'; import { Credentials } from 'ish-core/models/credentials/credentials.model'; @@ -13,6 +13,7 @@ import { PasswordReminder } from 'ish-core/models/password-reminder/password-rem import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { User } from 'ish-core/models/user/user.model'; import { MessagesPayloadType } from 'ish-core/store/core/messages'; +import { selectQueryParam } from 'ish-core/store/core/router'; import { getServerConfigParameter } from 'ish-core/store/core/server-config'; import { createCustomerAddress, @@ -38,6 +39,13 @@ import { loadMoreOrders, loadOrders, } from 'ish-core/store/customer/orders'; +import { + getRecurringOrders, + getRecurringOrdersError, + getRecurringOrdersLoading, + getSelectedRecurringOrder, + recurringOrdersActions, +} from 'ish-core/store/customer/recurring-orders'; import { cancelRegistration, getSsoRegistrationCancelled, @@ -195,6 +203,28 @@ export class AccountFacade { ordersLoading$ = this.store.pipe(select(getOrdersLoading)); ordersError$ = this.store.pipe(select(getOrdersError)); + // RECURRING ORDERS + + recurringOrdersContext$ = this.store.pipe(select(selectQueryParam('context')), distinctUntilChanged()); + selectedRecurringOrder$ = this.store.pipe(select(getSelectedRecurringOrder)); + recurringOrdersLoading$ = this.store.pipe(select(getRecurringOrdersLoading)); + recurringOrdersError$ = this.store.pipe(select(getRecurringOrdersError)); + + recurringOrders$() { + return this.recurringOrdersContext$.pipe( + tap(context => this.store.dispatch(recurringOrdersActions.loadRecurringOrders({ context }))), + switchMap(context => this.store.pipe(select(getRecurringOrders(context)))) + ); + } + + deleteRecurringOrder(id: string): void { + this.store.dispatch(recurringOrdersActions.deleteRecurringOrder({ recurringOrderId: id })); + } + + setActiveRecurringOrder(recurringOrderId: string, active: boolean) { + this.store.dispatch(recurringOrdersActions.updateRecurringOrder({ recurringOrderId, active })); + } + // PAYMENT private eligiblePaymentMethods$ = this.store.pipe(select(getUserPaymentMethods)); diff --git a/src/app/core/facades/checkout.facade.ts b/src/app/core/facades/checkout.facade.ts index 745dff3429..266d46955f 100644 --- a/src/app/core/facades/checkout.facade.ts +++ b/src/app/core/facades/checkout.facade.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Store, createSelector, select } from '@ngrx/store'; import { formatISO } from 'date-fns'; -import { Subject, combineLatest, merge } from 'rxjs'; +import { Subject, combineLatest, iif, merge } from 'rxjs'; import { debounceTime, distinctUntilChanged, filter, map, sample, switchMap, take, tap } from 'rxjs/operators'; import { Address } from 'ish-core/models/address/address.model'; @@ -10,7 +10,8 @@ import { CheckoutStepType } from 'ish-core/models/checkout/checkout-step.type'; import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-update.model'; import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { PriceType } from 'ish-core/models/price/price.model'; -import { selectRouteData } from 'ish-core/store/core/router'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; +import { selectQueryParam, selectRouteData } from 'ish-core/store/core/router'; import { getServerConfigParameter } from 'ish-core/store/core/server-config'; import { addMessageToMerchant, @@ -55,10 +56,12 @@ import { updateBasketAddress, updateBasketCostCenter, updateBasketItem, + updateBasketRecurrence, updateBasketShippingMethod, updateConcardisCvcLastUpdated, } from 'ish-core/store/customer/basket'; import { getOrdersError, getSelectedOrder } from 'ish-core/store/customer/orders'; +import { getRecurringOrder } from 'ish-core/store/customer/recurring-orders'; import { getLoggedInUser, getUserCostCenters, loadUserCostCenters } from 'ish-core/store/customer/user'; import { whenFalsy, whenTruthy } from 'ish-core/utils/operators'; @@ -146,6 +149,10 @@ export class CheckoutFacade { this.store.dispatch(updateBasketCostCenter({ costCenter })); } + updateBasketRecurrence(recurrence: Recurrence) { + this.store.dispatch(updateBasketRecurrence({ recurrence })); + } + updateBasketExternalOrderReference(externalOrderReference: string) { this.store.dispatch(updateBasket({ update: { externalOrderReference } })); } @@ -167,7 +174,19 @@ export class CheckoutFacade { private ordersError$ = this.store.pipe(select(getOrdersError)); basketOrOrdersError$ = merge(this.basketError$, this.ordersError$); - selectedOrder$ = this.store.pipe(select(getSelectedOrder)); + + submittedOrder$ = this.store.pipe( + select(selectQueryParam('recurringOrderId')), + switchMap(recurringOrderId => + iif( + () => !!recurringOrderId, + // fetch recurring order information if recurringOrderId is present + this.store.pipe(select(getRecurringOrder(recurringOrderId))), + // otherwise fetch the information for a standard order + this.store.pipe(select(getSelectedOrder)) + ) + ) + ); // SHIPPING diff --git a/src/app/core/icon.module.ts b/src/app/core/icon.module.ts index 272f8101ac..01cc5e063f 100644 --- a/src/app/core/icon.module.ts +++ b/src/app/core/icon.module.ts @@ -42,6 +42,7 @@ import { faPlus, faPrint, faQuestionCircle, + faRepeat, faRightFromBracket, faSearch, faShoppingCart, @@ -106,6 +107,7 @@ export class IconModule { faPlus, faPrint, faQuestionCircle, + faRepeat, faRightFromBracket, faSearch, faShoppingCart, diff --git a/src/app/core/models/basket/basket.interface.ts b/src/app/core/models/basket/basket.interface.ts index b90adbe20d..58efeea503 100644 --- a/src/app/core/models/basket/basket.interface.ts +++ b/src/app/core/models/basket/basket.interface.ts @@ -10,6 +10,7 @@ import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-in import { PaymentMethodBaseData } from 'ish-core/models/payment-method/payment-method.interface'; import { PaymentData } from 'ish-core/models/payment/payment.interface'; import { PriceItemData } from 'ish-core/models/price-item/price-item.interface'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; import { ShippingMethodData } from 'ish-core/models/shipping-method/shipping-method.interface'; export interface BasketBaseData { @@ -55,6 +56,7 @@ export interface BasketBaseData { firstName: string; lastName: string; }; + recurrence?: Recurrence; } export interface BasketIncludedData { diff --git a/src/app/core/models/basket/basket.mapper.ts b/src/app/core/models/basket/basket.mapper.ts index a044d8f264..203567db77 100644 --- a/src/app/core/models/basket/basket.mapper.ts +++ b/src/app/core/models/basket/basket.mapper.ts @@ -74,6 +74,7 @@ export class BasketMapper { user: data.buyer, externalOrderReference: data.externalOrderReference, messageToMerchant: data.messageToMerchant, + recurrence: data.recurrence, }; } diff --git a/src/app/core/models/basket/basket.model.ts b/src/app/core/models/basket/basket.model.ts index 550fb9c709..c0d9fc0016 100644 --- a/src/app/core/models/basket/basket.model.ts +++ b/src/app/core/models/basket/basket.model.ts @@ -6,6 +6,7 @@ import { BasketTotal } from 'ish-core/models/basket-total/basket-total.model'; import { BasketValidationResultType } from 'ish-core/models/basket-validation/basket-validation.model'; import { LineItem, LineItemView } from 'ish-core/models/line-item/line-item.model'; import { Payment } from 'ish-core/models/payment/payment.model'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; export interface AbstractBasket { @@ -36,6 +37,7 @@ export interface AbstractBasket { }; externalOrderReference?: string; messageToMerchant?: string; + recurrence?: Recurrence; } export type Basket = AbstractBasket; diff --git a/src/app/core/models/breadcrumb-item/breadcrumb-item.interface.ts b/src/app/core/models/breadcrumb-item/breadcrumb-item.interface.ts index a2e8cbbbde..516367feef 100644 --- a/src/app/core/models/breadcrumb-item/breadcrumb-item.interface.ts +++ b/src/app/core/models/breadcrumb-item/breadcrumb-item.interface.ts @@ -2,4 +2,5 @@ export interface BreadcrumbItem { key?: string; text?: string; link?: string | unknown[]; + linkParams?: { [key: string]: string }; } diff --git a/src/app/core/models/order/order.interface.ts b/src/app/core/models/order/order.interface.ts index 8fe539f8d2..1061f13f8c 100644 --- a/src/app/core/models/order/order.interface.ts +++ b/src/app/core/models/order/order.interface.ts @@ -17,7 +17,7 @@ export interface OrderBaseData extends BasketBaseData { status: 'COMPLETED' | 'ROLLED_BACK' | 'STOPPED' | 'CONTINUE'; stopAction?: { type: 'Redirect' | 'Workflow'; - exitReason?: 'waiting_for_pending_payments' | 'redirect_urls_required'; + exitReason?: 'waiting_for_pending_payments' | 'redirect_urls_required' | 'recurring.order'; redirectUrl?: string; }; }; @@ -25,6 +25,7 @@ export interface OrderBaseData extends BasketBaseData { status: string; basket: string; requisitionDocumentNo?: string; + recurringOrderID?: string; attributes?: Attribute[]; taxIdentificationNumber?: string; } diff --git a/src/app/core/models/order/order.mapper.ts b/src/app/core/models/order/order.mapper.ts index 17e58bf052..837a78aa73 100644 --- a/src/app/core/models/order/order.mapper.ts +++ b/src/app/core/models/order/order.mapper.ts @@ -23,6 +23,7 @@ export class OrderMapper { statusCode: data.statusCode, status: data.status, requisitionNo: data.requisitionDocumentNo, + recurringOrderID: data.recurringOrderID, approval: data.attributes && AttributeHelper.getAttributeValueByAttributeName( diff --git a/src/app/core/models/order/order.model.ts b/src/app/core/models/order/order.model.ts index f100cc1e2f..e887529314 100644 --- a/src/app/core/models/order/order.model.ts +++ b/src/app/core/models/order/order.model.ts @@ -16,7 +16,7 @@ export interface Order extends OrderBasket { status: 'COMPLETED' | 'ROLLED_BACK' | 'STOPPED' | 'CONTINUE'; stopAction?: { type: 'Redirect' | 'Workflow'; - exitReason?: 'waiting_for_pending_payments' | 'redirect_urls_required'; + exitReason?: 'waiting_for_pending_payments' | 'redirect_urls_required' | 'recurring.order'; redirectUrl?: string; }; }; @@ -28,4 +28,5 @@ export interface Order extends OrderBasket { date: number; }; requisitionNo?: string; + recurringOrderID?: string; } diff --git a/src/app/core/models/price/price.interface.ts b/src/app/core/models/price/price.interface.ts index 8092206d7a..cee4665b18 100644 --- a/src/app/core/models/price/price.interface.ts +++ b/src/app/core/models/price/price.interface.ts @@ -2,3 +2,8 @@ export interface PriceData { value: number; currency: string; } + +export interface PriceAmountData { + amount: number; + currency: string; +} diff --git a/src/app/core/models/recurrence/recurrence.model.ts b/src/app/core/models/recurrence/recurrence.model.ts new file mode 100644 index 0000000000..cb4e0a0f70 --- /dev/null +++ b/src/app/core/models/recurrence/recurrence.model.ts @@ -0,0 +1,6 @@ +export interface Recurrence { + interval: string; + startDate: string; + endDate?: string; + repetitions?: number; +} diff --git a/src/app/core/models/recurring-order/recurring-order.interface.ts b/src/app/core/models/recurring-order/recurring-order.interface.ts new file mode 100644 index 0000000000..89729cbd54 --- /dev/null +++ b/src/app/core/models/recurring-order/recurring-order.interface.ts @@ -0,0 +1,69 @@ +import { AddressData } from 'ish-core/models/address/address.interface'; +import { BasketApprover } from 'ish-core/models/basket-approval/basket-approval.model'; +import { BasketTotalData } from 'ish-core/models/basket-total/basket-total.interface'; +import { LineItemData } from 'ish-core/models/line-item/line-item.interface'; +import { PaymentMethodBaseData } from 'ish-core/models/payment-method/payment-method.interface'; +import { PaymentData } from 'ish-core/models/payment/payment.interface'; +import { PriceAmountData } from 'ish-core/models/price/price.interface'; +import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; + +export interface RecurringOrderData extends Omit { + orderCount?: number; + totals: BasketTotalData; + // error flag if the recurring orders was set to inactive by the system + error?: boolean; + errorCode?: string; + statusCode?: string; + + lineItems: LineItemData[]; + shippingBuckets?: [ + { + shipToAddress: string; + shippingMethod: string; + } + ]; + shippingMethods?: ShippingMethod[]; + + addresses?: AddressData[]; + invoiceToAddress?: string; + + approvalStatuses?: { approvalDate: number; approver: BasketApprover; statusCode: string }[]; + + costCenterID?: string; + costCenterName?: string; + + payments?: PaymentData[]; + paymentMethods?: PaymentMethodBaseData[]; + lastOrders?: [{ id: string; documentNumber: string; creationDate: string }]; +} + +export interface RecurringOrderListData { + id: string; + number: string; + active: boolean; + expired: boolean; + + interval: string; + startDate: string; + endDate?: string; + repetitions?: number; + + creationDate: string; + lastOrderDate?: string; + nextOrderDate?: string; + + buyer: BuyerData; + itemCount: number; + totalNet: PriceAmountData; + totalGross: PriceAmountData; +} + +interface BuyerData { + accountID: string; + companyName?: string; + customerNo: string; + email: string; + firstName: string; + lastName: string; + userNo: string; +} diff --git a/src/app/core/models/recurring-order/recurring-order.mapper.spec.ts b/src/app/core/models/recurring-order/recurring-order.mapper.spec.ts new file mode 100644 index 0000000000..3079ace6d3 --- /dev/null +++ b/src/app/core/models/recurring-order/recurring-order.mapper.spec.ts @@ -0,0 +1,31 @@ +import { LineItemData } from 'ish-core/models/line-item/line-item.interface'; + +import { RecurringOrderData } from './recurring-order.interface'; +import { RecurringOrderMapper } from './recurring-order.mapper'; +import { RecurringOrder } from './recurring-order.model'; + +describe('Recurring Order Mapper', () => { + const recurringOrderData = { + number: '0000045', + id: 'TEwK8ITr4AQAAAGRGCQADlo0', + buyer: { + companyName: 'Oil Corp', + }, + lineItems: [ + { + calculated: false, + } as LineItemData, + ], + payments: [{ id: 'payment_1' }], + paymentMethods: [{ id: 'payment_method_1' }], + } as RecurringOrderData; + + describe('fromData', () => { + let recurringOrder: RecurringOrder; + + it(`should return RecurringOrder when getting RecurringOrderData`, () => { + recurringOrder = RecurringOrderMapper.fromData(recurringOrderData); + expect(recurringOrder).toBeTruthy(); + }); + }); +}); diff --git a/src/app/core/models/recurring-order/recurring-order.mapper.ts b/src/app/core/models/recurring-order/recurring-order.mapper.ts new file mode 100644 index 0000000000..cbfe627056 --- /dev/null +++ b/src/app/core/models/recurring-order/recurring-order.mapper.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@angular/core'; + +import { BasketBaseData } from 'ish-core/models/basket/basket.interface'; +import { BasketMapper } from 'ish-core/models/basket/basket.mapper'; +import { LineItemMapper } from 'ish-core/models/line-item/line-item.mapper'; +import { PaymentMapper } from 'ish-core/models/payment/payment.mapper'; + +import { RecurringOrderData, RecurringOrderListData } from './recurring-order.interface'; +import { RecurringOrder } from './recurring-order.model'; + +@Injectable({ providedIn: 'root' }) +export class RecurringOrderMapper { + static fromListData(recurringOrderData: RecurringOrderListData[]): RecurringOrder[] { + if (!recurringOrderData.length) { + return []; + } + return recurringOrderData.map(data => ({ + id: data.id, + documentNo: data.number, + active: data.active, + expired: data.expired, + + recurrence: { + interval: data.interval, + startDate: data.startDate, + endDate: data.endDate, + repetitions: data.repetitions, + }, + + creationDate: data.creationDate, + lastOrderDate: data.lastOrderDate, + nextOrderDate: data.nextOrderDate, + + customerNo: data.buyer.customerNo, + email: data.buyer.email, + user: { + email: data.buyer.email, + firstName: data.buyer.firstName, + lastName: data.buyer.lastName, + companyName: data.buyer.companyName, + }, + + totals: { + total: { + type: 'PriceItem', + gross: data.totalGross.amount, + net: data.totalNet.amount, + currency: data.totalGross.currency, + }, + // required properties for 'totals' + itemTotal: undefined, + isEstimated: false, + }, + })); + } + + static fromData(data: RecurringOrderData): RecurringOrder { + if (!data) { + return; + } + + return { + id: data.id, + documentNo: data.number, + active: data.active, + expired: data.expired, + error: data.error, + errorCode: data.errorCode, + statusCode: data.statusCode, + + recurrence: { + interval: data.interval, + startDate: data.startDate, + endDate: data.endDate, + repetitions: data.repetitions, + }, + + creationDate: data.creationDate, + lastOrderDate: data.lastOrderDate, + nextOrderDate: data.nextOrderDate, + orderCount: data.orderCount, + costCenterId: data.costCenterID, + costCenterName: data.costCenterName, + + customerNo: data.buyer.customerNo, + email: data.buyer.email, + user: { + email: data.buyer.email, + firstName: data.buyer.firstName, + lastName: data.buyer.lastName, + companyName: data.buyer.companyName, + }, + + invoiceToAddress: data.addresses?.find(address => address.urn === data.invoiceToAddress), + commonShipToAddress: data.addresses?.find(address => address.urn === data.shippingBuckets?.[0]?.shipToAddress), + commonShippingMethod: data.shippingMethods?.find( + methods => methods.id === data.shippingBuckets?.[0]?.shippingMethod + ), + + payment: PaymentMapper.fromIncludeData(data.payments[0], data.paymentMethods[0], undefined), + + approvalStatuses: data.approvalStatuses + ?.map(status => ({ + approvalDate: status.approvalDate, + approver: status.approver, + statusCode: status.statusCode, + })) + .filter( + (value, index, self) => + index === + self.findIndex( + t => + t.approvalDate === value.approvalDate && + t.approver.firstName === value.approver.firstName && + t.approver.lastName === value.approver.lastName + ) + ), + + lastPlacedOrders: data.lastOrders, + + lineItems: data.lineItems.map(lineItem => LineItemMapper.fromData(lineItem)), + + totals: BasketMapper.getTotals(data as unknown as BasketBaseData), + }; + } +} diff --git a/src/app/core/models/recurring-order/recurring-order.model.ts b/src/app/core/models/recurring-order/recurring-order.model.ts new file mode 100644 index 0000000000..478abee449 --- /dev/null +++ b/src/app/core/models/recurring-order/recurring-order.model.ts @@ -0,0 +1,31 @@ +import { BasketApprover } from 'ish-core/models/basket-approval/basket-approval.model'; +import { AbstractBasket } from 'ish-core/models/basket/basket.model'; +import { LineItem } from 'ish-core/models/line-item/line-item.model'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; +import { User } from 'ish-core/models/user/user.model'; + +type OrderBasket = Omit, 'approval'>; + +export interface RecurringOrder extends OrderBasket { + documentNo: string; + active: boolean; + expired: boolean; + // error flag if the recurring orders was set to inactive by the system + error?: boolean; + errorCode?: string; + statusCode?: string; + + recurrence: Recurrence; + + creationDate: string; + lastOrderDate?: string; + nextOrderDate?: string; + orderCount?: number; + costCenterId?: string; + costCenterName?: string; + approvalStatuses?: { approvalDate: number; approver: BasketApprover; statusCode: string }[]; + + lastPlacedOrders?: { id: string; documentNumber: string; creationDate: string }[]; + + user: User & { companyName: string }; +} diff --git a/src/app/core/pipes.module.ts b/src/app/core/pipes.module.ts index ed532a3500..7b598f3b79 100644 --- a/src/app/core/pipes.module.ts +++ b/src/app/core/pipes.module.ts @@ -4,6 +4,7 @@ import { AttributeToStringPipe } from './models/attribute/attribute.pipe'; import { PricePipe } from './models/price/price.pipe'; import { DatePipe } from './pipes/date.pipe'; import { FeatureTogglePipe } from './pipes/feature-toggle.pipe'; +import { FrequencyPipe } from './pipes/frequency.pipe'; import { HighlightPipe } from './pipes/highlight.pipe'; import { HtmlEncodePipe } from './pipes/html-encode.pipe'; import { MakeHrefPipe } from './pipes/make-href.pipe'; @@ -20,6 +21,7 @@ const pipes = [ ContentPageRoutePipe, DatePipe, FeatureTogglePipe, + FrequencyPipe, HighlightPipe, HtmlEncodePipe, MakeHrefPipe, diff --git a/src/app/core/pipes/frequency.pipe.ts b/src/app/core/pipes/frequency.pipe.ts new file mode 100644 index 0000000000..4453273417 --- /dev/null +++ b/src/app/core/pipes/frequency.pipe.ts @@ -0,0 +1,35 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * The frequency pipe converts a string of special interval format to the corresponding display string + * example: interval 'P21D' returns '3 Week(s)' + */ +@Pipe({ name: 'ishFrequency' }) +export class FrequencyPipe implements PipeTransform { + constructor(private translate: TranslateService) {} + + transform(interval: string): string { + let period = interval.slice(-1).toUpperCase(); + let duration = parseInt(interval.slice(1, -1), 10); + + // convert days to weeks if possible since the API only returns daily, monthly or yearly intervals + if (period === 'D' && duration % 7 === 0) { + period = 'W'; + duration = duration / 7; + } + + switch (period) { + case 'D': + return this.translate.instant('order.recurrence.period.days', { 0: duration }); + case 'W': + return this.translate.instant('order.recurrence.period.weeks', { 0: duration }); + case 'M': + return this.translate.instant('order.recurrence.period.months', { 0: duration }); + case 'Y': + return this.translate.instant('order.recurrence.period.years', { 0: duration }); + default: + return interval; + } + } +} diff --git a/src/app/core/services/basket/basket.service.ts b/src/app/core/services/basket/basket.service.ts index 3968a11220..a450bc89a7 100644 --- a/src/app/core/services/basket/basket.service.ts +++ b/src/app/core/services/basket/basket.service.ts @@ -17,6 +17,7 @@ import { BasketValidation, BasketValidationScopeType } from 'ish-core/models/bas import { BasketBaseData, BasketData } from 'ish-core/models/basket/basket.interface'; import { BasketMapper } from 'ish-core/models/basket/basket.mapper'; import { Basket } from 'ish-core/models/basket/basket.model'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; import { ShippingMethodData } from 'ish-core/models/shipping-method/shipping-method.interface'; import { ShippingMethodMapper } from 'ish-core/models/shipping-method/shipping-method.mapper'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; @@ -30,7 +31,8 @@ export type BasketUpdateType = | { costCenter: string } | { externalOrderReference: string } | { invoiceToAddress: string } - | { messageToMerchant: string }; + | { messageToMerchant: string } + | { recurrence: Recurrence }; /** * The Basket Service handles the interaction with the 'baskets' REST API. diff --git a/src/app/core/services/recurring-orders/recurring-orders.service.spec.ts b/src/app/core/services/recurring-orders/recurring-orders.service.spec.ts new file mode 100644 index 0000000000..a00c675d57 --- /dev/null +++ b/src/app/core/services/recurring-orders/recurring-orders.service.spec.ts @@ -0,0 +1,28 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; +import { instance, mock } from 'ts-mockito'; + +import { ApiService } from 'ish-core/services/api/api.service'; +import { getLoggedInCustomer } from 'ish-core/store/customer/user'; + +import { RecurringOrdersService } from './recurring-orders.service'; + +describe('Recurring Orders Service', () => { + let apiServiceMock: ApiService; + let recurringOrdersService: RecurringOrdersService; + + beforeEach(() => { + apiServiceMock = mock(ApiService); + TestBed.configureTestingModule({ + providers: [ + { provide: ApiService, useFactory: () => instance(apiServiceMock) }, + provideMockStore({ selectors: [{ selector: getLoggedInCustomer, value: undefined }] }), + ], + }); + recurringOrdersService = TestBed.inject(RecurringOrdersService); + }); + + it('should be created', () => { + expect(recurringOrdersService).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/recurring-orders/recurring-orders.service.ts b/src/app/core/services/recurring-orders/recurring-orders.service.ts new file mode 100644 index 0000000000..a4062f52cc --- /dev/null +++ b/src/app/core/services/recurring-orders/recurring-orders.service.ts @@ -0,0 +1,104 @@ +import { HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { combineLatest, filter, map, switchMap, take, throwError } from 'rxjs'; + +import { RecurringOrderData, RecurringOrderListData } from 'ish-core/models/recurring-order/recurring-order.interface'; +import { RecurringOrderMapper } from 'ish-core/models/recurring-order/recurring-order.mapper'; +import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; +import { getLoggedInCustomer, getLoggedInUser } from 'ish-core/store/customer/user'; + +@Injectable({ providedIn: 'root' }) +export class RecurringOrdersService { + constructor(private apiService: ApiService, private store: Store) {} + + private recurringOrderHeaderV2 = new HttpHeaders({ Accept: 'application/vnd.intershop.recurringorder.v2+json' }); + + private currentCustomerAndUser$ = combineLatest([ + this.store.pipe(select(getLoggedInCustomer)), + this.store.pipe(select(getLoggedInUser)), + ]).pipe( + filter(([customer, user]) => !!customer && !!user), + take(1) + ); + + private getRecurringOrdersEndpoint(context?: string) { + return this.currentCustomerAndUser$.pipe( + map(([customer, user]) => { + let apiEndpoint: string = undefined; + if (customer.isBusinessCustomer) { + if (context === 'ADMIN') { + apiEndpoint = `customers/${this.apiService.encodeResourceId(customer.customerNo)}/recurringorders`; + // admin users could request user specific recurring orders - not used in UI yet + // } else if (context) { + // apiEndpoint = `customers/${this.apiService.encodeResourceId( + // customer.customerNo + // )}/users/${this.apiService.encodeResourceId(context)}/recurringorders`; + } else { + apiEndpoint = `customers/${this.apiService.encodeResourceId( + customer.customerNo + )}/users/${this.apiService.encodeResourceId(user.login)}/recurringorders`; + } + } else { + apiEndpoint = `privatecustomers/${this.apiService.encodeResourceId(customer.customerNo)}/recurringorders`; + } + return apiEndpoint; + }) + ); + } + + getRecurringOrders(context?: string) { + return this.getRecurringOrdersEndpoint(context).pipe( + switchMap(apiEndpoint => + this.apiService.get(apiEndpoint, { headers: this.recurringOrderHeaderV2 }).pipe( + unpackEnvelope('data'), + map(data => RecurringOrderMapper.fromListData(data)) + ) + ) + ); + } + + getRecurringOrder(recurringOrderId: string, context?: string) { + return this.getRecurringOrdersEndpoint(context).pipe( + switchMap(apiEndpoint => + this.apiService + .get<{ data: RecurringOrderData }>(`${apiEndpoint}/${this.apiService.encodeResourceId(recurringOrderId)}`, { + headers: this.recurringOrderHeaderV2, + }) + .pipe(map(data => RecurringOrderMapper.fromData(data.data))) + ) + ); + } + + updateRecurringOrder(recurringOrderId: string, active: boolean, context?: string) { + if (!recurringOrderId) { + return throwError(() => new Error('updateRecurringOrder() called without recurringOrderId')); + } + + return this.getRecurringOrdersEndpoint(context).pipe( + switchMap(apiEndpoint => + this.apiService + .patch<{ data: RecurringOrderData }>( + `${apiEndpoint}/${this.apiService.encodeResourceId(recurringOrderId)}`, + { active }, + { headers: this.recurringOrderHeaderV2 } + ) + .pipe(map(data => RecurringOrderMapper.fromData(data.data))) + ) + ); + } + + deleteRecurringOrder(recurringOrderId: string, context?: string) { + if (!recurringOrderId) { + return throwError(() => new Error('deleteRecurringOrder() called without recurringOrderId')); + } + + return this.getRecurringOrdersEndpoint(context).pipe( + switchMap(apiEndpoint => + this.apiService.delete(`${apiEndpoint}/${this.apiService.encodeResourceId(recurringOrderId)}`, { + headers: this.recurringOrderHeaderV2, + }) + ) + ); + } +} diff --git a/src/app/core/store/customer/basket/basket.actions.ts b/src/app/core/store/customer/basket/basket.actions.ts index 661fcb2100..7c0a8cd1ce 100644 --- a/src/app/core/store/customer/basket/basket.actions.ts +++ b/src/app/core/store/customer/basket/basket.actions.ts @@ -12,6 +12,7 @@ import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-updat import { AddLineItemType, LineItem } from 'ish-core/models/line-item/line-item.model'; import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { PaymentMethod } from 'ish-core/models/payment-method/payment-method.model'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; import { BasketUpdateType } from 'ish-core/services/basket/basket.service'; import { httpError, payload } from 'ish-core/utils/ngrx-creators'; @@ -68,6 +69,12 @@ export const updateBasketCostCenter = createAction( '[Basket] Assign a Cost Center at Basket ', payload<{ costCenter: string }>() ); + +export const updateBasketRecurrence = createAction( + '[Basket] Set the Recurrence Information at Basket ', + payload<{ recurrence: Recurrence }>() +); + export const addMessageToMerchant = createAction( '[Basket] Message to Merchant', payload<{ messageToMerchant: string }>() diff --git a/src/app/core/store/customer/basket/basket.effects.ts b/src/app/core/store/customer/basket/basket.effects.ts index 30c59b4bfe..c6e416cee3 100644 --- a/src/app/core/store/customer/basket/basket.effects.ts +++ b/src/app/core/store/customer/basket/basket.effects.ts @@ -63,6 +63,7 @@ import { updateBasket, updateBasketCostCenter, updateBasketFail, + updateBasketRecurrence, updateBasketShippingMethod, } from './basket.actions'; import { getCurrentBasket, getCurrentBasketId } from './basket.selectors'; @@ -211,6 +212,17 @@ export class BasketEffects { ) ); + /** + * Sets the recurrence at the current basket. + */ + updateBasketRecurrence$ = createEffect(() => + this.actions$.pipe( + ofType(updateBasketRecurrence), + mapToPayloadProperty('recurrence'), + map(recurrence => updateBasket({ update: { recurrence } })) + ) + ); + /** * Sets a message to merchant at the current basket. */ diff --git a/src/app/core/store/customer/customer-store.module.ts b/src/app/core/store/customer/customer-store.module.ts index f46ac2df8c..ba513a4e63 100644 --- a/src/app/core/store/customer/customer-store.module.ts +++ b/src/app/core/store/customer/customer-store.module.ts @@ -22,6 +22,8 @@ import { dataRequestsReducer } from './data-requests/data-requests.reducer'; import { OrdersEffects } from './orders/orders.effects'; import { ordersReducer } from './orders/orders.reducer'; import { OrganizationManagementEffects } from './organization-management/organization-management.effects'; +import { RecurringOrdersEffects } from './recurring-orders/recurring-orders.effects'; +import { recurringOrdersReducer } from './recurring-orders/recurring-orders.reducer'; import { RequisitionManagementEffects } from './requisition-management/requisition-management.effects'; import { SsoRegistrationEffects } from './sso-registration/sso-registration.effects'; import { ssoRegistrationReducer } from './sso-registration/sso-registration.reducer'; @@ -33,6 +35,7 @@ const customerReducers: ActionReducerMap = { user: userReducer, addresses: addressesReducer, orders: ordersReducer, + recurringOrders: recurringOrdersReducer, basket: basketReducer, authorization: authorizationReducer, ssoRegistration: ssoRegistrationReducer, @@ -48,6 +51,7 @@ const customerEffects = [ BasketPromotionCodeEffects, BasketValidationEffects, OrdersEffects, + RecurringOrdersEffects, UserEffects, AuthorizationEffects, OrganizationManagementEffects, diff --git a/src/app/core/store/customer/customer-store.spec.ts b/src/app/core/store/customer/customer-store.spec.ts index faa98e54fd..2d1d64c411 100644 --- a/src/app/core/store/customer/customer-store.spec.ts +++ b/src/app/core/store/customer/customer-store.spec.ts @@ -27,6 +27,7 @@ import { PaymentService } from 'ish-core/services/payment/payment.service'; import { PricesService } from 'ish-core/services/prices/prices.service'; import { ProductsService } from 'ish-core/services/products/products.service'; import { PromotionsService } from 'ish-core/services/promotions/promotions.service'; +import { RecurringOrdersService } from 'ish-core/services/recurring-orders/recurring-orders.service'; import { SuggestService } from 'ish-core/services/suggest/suggest.service'; import { TokenService } from 'ish-core/services/token/token.service'; import { UserService } from 'ish-core/services/user/user.service'; @@ -186,6 +187,7 @@ describe('Customer Store', () => { { provide: PricesService, useFactory: () => instance(productPriceServiceMock) }, { provide: ProductsService, useFactory: () => instance(productsServiceMock) }, { provide: PromotionsService, useFactory: () => instance(promotionsServiceMock) }, + { provide: RecurringOrdersService, useFactory: () => instance(mock(RecurringOrdersService)) }, { provide: SuggestService, useFactory: () => instance(mock(SuggestService)) }, { provide: TokenService, useFactory: () => instance(mock(TokenService)) }, { provide: UserService, useFactory: () => instance(userServiceMock) }, diff --git a/src/app/core/store/customer/customer-store.ts b/src/app/core/store/customer/customer-store.ts index 84c00b8663..a0d2589a73 100644 --- a/src/app/core/store/customer/customer-store.ts +++ b/src/app/core/store/customer/customer-store.ts @@ -6,6 +6,7 @@ import { AddressesState } from './addresses/addresses.reducer'; import { BasketState } from './basket/basket.reducer'; import { DataRequestsState } from './data-requests/data-requests.reducer'; import { OrdersState } from './orders/orders.reducer'; +import { RecurringOrdersState } from './recurring-orders/recurring-orders.reducer'; import { SsoRegistrationState } from './sso-registration/sso-registration.reducer'; import { UserState } from './user/user.reducer'; @@ -13,6 +14,7 @@ export interface CustomerState { user: UserState; addresses: AddressesState; orders: OrdersState; + recurringOrders: RecurringOrdersState; basket: BasketState; authorization: Authorization; ssoRegistration: SsoRegistrationState; diff --git a/src/app/core/store/customer/orders/orders.actions.ts b/src/app/core/store/customer/orders/orders.actions.ts index 5040f57771..65d14123c1 100644 --- a/src/app/core/store/customer/orders/orders.actions.ts +++ b/src/app/core/store/customer/orders/orders.actions.ts @@ -9,7 +9,10 @@ export const createOrder = createAction('[Orders Internal] Create Order'); export const createOrderFail = createAction('[Orders API] Create Order Fail', httpError()); -export const createOrderSuccess = createAction('[Orders API] Create Order Success', payload<{ order: Order }>()); +export const createOrderSuccess = createAction( + '[Orders API] Create Order Success', + payload<{ order: Order; basketId: string }>() +); export const loadOrders = createAction('[Orders] Load Orders', payload<{ query: OrderListQuery }>()); diff --git a/src/app/core/store/customer/orders/orders.effects.spec.ts b/src/app/core/store/customer/orders/orders.effects.spec.ts index f2cf8577e1..954ab9349c 100644 --- a/src/app/core/store/customer/orders/orders.effects.spec.ts +++ b/src/app/core/store/customer/orders/orders.effects.spec.ts @@ -112,7 +112,7 @@ describe('Orders Effects', () => { const basketId = BasketMockData.getBasket().id; const newOrder = { id: basketId } as Order; const action = createOrder(); - const completion = createOrderSuccess({ order: newOrder }); + const completion = createOrderSuccess({ order: newOrder, basketId: 'BID' }); actions$ = hot('-a-a-a', { a: action }); const expected$ = cold('-c-c-c', { c: completion }); @@ -134,7 +134,7 @@ describe('Orders Effects', () => { describe('continueAfterOrderCreation', () => { it('should navigate to /checkout/receipt after CreateOrderSuccess if there is no redirect required', fakeAsync(() => { - const action = createOrderSuccess({ order: { id: '123' } as Order }); + const action = createOrderSuccess({ order: { id: '123' } as Order, basketId: 'BID' }); actions$ = of(action); effects.continueAfterOrderCreation$.subscribe({ next: noop, error: fail, complete: noop }); @@ -156,6 +156,7 @@ describe('Orders Effects', () => { id: '123', orderCreation: { status: 'STOPPED', stopAction: { type: 'Redirect', redirectUrl: 'http://test' } }, } as Order, + basketId: 'BID', }); actions$ = of(action); @@ -175,6 +176,7 @@ describe('Orders Effects', () => { orderCreation: { status: 'ROLLED_BACK' }, infos: [{ message: 'Info' }], } as Order, + basketId: 'BID', }); actions$ = of(action); @@ -383,7 +385,7 @@ describe('Orders Effects', () => { }); it('should trigger SelectOrderAfterRedirect action if checkout payment/receipt page is called with query param "redirect" and an order is available', done => { - store.dispatch(createOrderSuccess({ order })); + store.dispatch(createOrderSuccess({ order, basketId: 'BID' })); router.navigate(['checkout', 'receipt'], { queryParams: { redirect: 'success', param1: 123, orderId: order.id }, diff --git a/src/app/core/store/customer/orders/orders.effects.ts b/src/app/core/store/customer/orders/orders.effects.ts index ac82663175..df2b695a38 100644 --- a/src/app/core/store/customer/orders/orders.effects.ts +++ b/src/app/core/store/customer/orders/orders.effects.ts @@ -52,7 +52,7 @@ export class OrdersEffects { concatLatestFrom(() => this.store.pipe(select(getCurrentBasketId))), mergeMap(([, basketId]) => this.orderService.createOrder(basketId, true).pipe( - map(order => createOrderSuccess({ order })), + map(order => createOrderSuccess({ order, basketId })), mapErrorToAction(createOrderFail) ) ) @@ -66,9 +66,9 @@ export class OrdersEffects { () => this.actions$.pipe( ofType(createOrderSuccess), - mapToPayloadProperty('order'), - filter(order => !order?.orderCreation || order.orderCreation.status !== 'ROLLED_BACK'), - concatMap(order => { + mapToPayload(), + filter(({ order }) => !order?.orderCreation || order.orderCreation.status !== 'ROLLED_BACK'), + concatMap(({ order, basketId }) => { if ( order.orderCreation && order.orderCreation.status === 'STOPPED' && @@ -77,6 +77,11 @@ export class OrdersEffects { ) { location.assign(order.orderCreation.stopAction.redirectUrl); return EMPTY; + } else if ( + order.orderCreation?.status === 'STOPPED' && + order.orderCreation.stopAction.exitReason === 'recurring.order' + ) { + return from(this.router.navigate(['/checkout/receipt'], { queryParams: { recurringOrderId: basketId } })); } else { return from(this.router.navigate(['/checkout/receipt'], { queryParams: { orderId: order.id } })); } diff --git a/src/app/core/store/customer/orders/orders.reducer.ts b/src/app/core/store/customer/orders/orders.reducer.ts index 8b62c66cac..5cb4577c9c 100644 --- a/src/app/core/store/customer/orders/orders.reducer.ts +++ b/src/app/core/store/customer/orders/orders.reducer.ts @@ -56,10 +56,12 @@ export const ordersReducer = createReducer( on(createOrderSuccess, loadOrderSuccess, (state, action) => { const { order } = action.payload; - return { - ...orderAdapter.upsertOne(order, state), - selected: order.id, - }; + return order.id + ? { + ...orderAdapter.upsertOne(order, state), + selected: order.id, + } + : state; }), on(loadOrdersSuccess, (state, action) => { const { orders, query, allRetrieved } = action.payload; diff --git a/src/app/core/store/customer/recurring-orders/index.ts b/src/app/core/store/customer/recurring-orders/index.ts new file mode 100644 index 0000000000..5dc19edf96 --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/index.ts @@ -0,0 +1,3 @@ +// API to access ngrx recurringOrders state +export * from './recurring-orders.actions'; +export * from './recurring-orders.selectors'; diff --git a/src/app/core/store/customer/recurring-orders/recurring-orders.actions.ts b/src/app/core/store/customer/recurring-orders/recurring-orders.actions.ts new file mode 100644 index 0000000000..3485dabb6a --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/recurring-orders.actions.ts @@ -0,0 +1,28 @@ +import { createActionGroup } from '@ngrx/store'; + +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { httpError, payload } from 'ish-core/utils/ngrx-creators'; + +export const recurringOrdersActions = createActionGroup({ + source: 'Recurring Orders', + events: { + 'Load Recurring Orders': payload<{ context: string }>(), + 'Load Recurring Order': payload<{ recurringOrderId: string }>(), + 'Update Recurring Order': payload<{ recurringOrderId: string; active: boolean }>(), + 'Delete Recurring Order': payload<{ recurringOrderId: string }>(), + }, +}); + +export const recurringOrdersApiActions = createActionGroup({ + source: 'Recurring Orders API', + events: { + 'Load Recurring Orders Success': payload<{ recurringOrders: RecurringOrder[]; context: string }>(), + 'Load Recurring Orders Fail': httpError<{}>(), + 'Load Recurring Order Success': payload<{ recurringOrder: RecurringOrder }>(), + 'Load Recurring Order Fail': httpError<{}>(), + 'Update Recurring Order Success': payload<{ recurringOrder: RecurringOrder }>(), + 'Update Recurring Order Fail': httpError<{}>(), + 'Delete Recurring Order Success': payload<{ recurringOrderId: string }>(), + 'Delete Recurring Order Fail': httpError<{}>(), + }, +}); diff --git a/src/app/core/store/customer/recurring-orders/recurring-orders.effects.spec.ts b/src/app/core/store/customer/recurring-orders/recurring-orders.effects.spec.ts new file mode 100644 index 0000000000..c243e34061 --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/recurring-orders.effects.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable, of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { RecurringOrdersService } from 'ish-core/services/recurring-orders/recurring-orders.service'; +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; + +import { recurringOrdersActions } from './recurring-orders.actions'; +import { RecurringOrdersEffects } from './recurring-orders.effects'; + +describe('Recurring Orders Effects', () => { + let actions$: Observable; + let effects: RecurringOrdersEffects; + let recurringOrdersServiceMock: RecurringOrdersService; + + const order = { documentNo: '0000001', id: '1', active: true, lineItems: [] } as RecurringOrder; + const recurringOrders = [order, { number: '0000002', id: '2', active: true, lineItems: [] }] as RecurringOrder[]; + + beforeEach(() => { + recurringOrdersServiceMock = mock(RecurringOrdersService); + when(recurringOrdersServiceMock.getRecurringOrders(anything())).thenReturn(of(recurringOrders)); + + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), TranslateModule.forRoot()], + providers: [ + { provide: RecurringOrdersService, useFactory: () => instance(recurringOrdersServiceMock) }, + provideMockActions(() => actions$), + RecurringOrdersEffects, + ], + }); + + effects = TestBed.inject(RecurringOrdersEffects); + }); + + describe('loadRecurringOrders$', () => { + it('should call the RecurringOrderService for loadRecurringOrders', done => { + const action = recurringOrdersActions.loadRecurringOrders({ context: 'MY' }); + actions$ = of(action); + + effects.loadRecurringOrders$.subscribe(() => { + verify(recurringOrdersServiceMock.getRecurringOrders(anything())).once(); + done(); + }); + }); + }); +}); diff --git a/src/app/core/store/customer/recurring-orders/recurring-orders.effects.ts b/src/app/core/store/customer/recurring-orders/recurring-orders.effects.ts new file mode 100644 index 0000000000..766fb5d0be --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/recurring-orders.effects.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@angular/core'; +import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; +import { routerNavigatedAction } from '@ngrx/router-store'; +import { Store, select } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { merge } from 'rxjs'; +import { concatMap, map, mergeMap, switchMap } from 'rxjs/operators'; + +import { RecurringOrdersService } from 'ish-core/services/recurring-orders/recurring-orders.service'; +import { displaySuccessMessage } from 'ish-core/store/core/messages'; +import { ofUrl, selectQueryParam, selectRouteParam } from 'ish-core/store/core/router'; +import { setBreadcrumbData } from 'ish-core/store/core/viewconf'; +import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators'; + +import { recurringOrdersActions, recurringOrdersApiActions } from './recurring-orders.actions'; +import { getRecurringOrder, getSelectedRecurringOrder } from './recurring-orders.selectors'; + +@Injectable() +export class RecurringOrdersEffects { + constructor( + private actions$: Actions, + private recurringOrdersService: RecurringOrdersService, + private store: Store, + private translateService: TranslateService + ) {} + + loadRecurringOrders$ = createEffect(() => + this.actions$.pipe( + ofType(recurringOrdersActions.loadRecurringOrders), + mapToPayloadProperty('context'), + concatMap(context => + this.recurringOrdersService.getRecurringOrders(context).pipe( + map(recurringOrders => recurringOrdersApiActions.loadRecurringOrdersSuccess({ recurringOrders, context })), + mapErrorToAction(recurringOrdersApiActions.loadRecurringOrdersFail) + ) + ) + ) + ); + + loadRecurringOrder$ = createEffect(() => + this.actions$.pipe( + ofType(recurringOrdersActions.loadRecurringOrder), + mapToPayloadProperty('recurringOrderId'), + concatLatestFrom(() => this.store.pipe(select(selectQueryParam('context')))), + switchMap(([recurringOrderId, context]) => + this.recurringOrdersService.getRecurringOrder(recurringOrderId, context).pipe( + map(recurringOrder => recurringOrdersApiActions.loadRecurringOrderSuccess({ recurringOrder })), + mapErrorToAction(recurringOrdersApiActions.loadRecurringOrderFail) + ) + ) + ) + ); + + triggerLoadRecurringOrder$ = createEffect(() => + merge( + this.store.pipe(select(selectRouteParam('recurringOrderId'))), + this.store.pipe(select(selectQueryParam('recurringOrderId'))) + ).pipe( + whenTruthy(), + map(recurringOrderId => recurringOrdersActions.loadRecurringOrder({ recurringOrderId })) + ) + ); + + updateRecurringOrder$ = createEffect(() => + this.actions$.pipe( + ofType(recurringOrdersActions.updateRecurringOrder), + mapToPayload(), + whenTruthy(), + concatLatestFrom(payload => this.store.pipe(select(getRecurringOrder(payload.recurringOrderId)))), + concatLatestFrom(() => this.store.pipe(select(selectQueryParam('context')))), + mergeMap(([[payload, recurringOrder], context]) => { + if (payload.active !== recurringOrder.active) { + return this.recurringOrdersService + .updateRecurringOrder(payload.recurringOrderId, payload.active, context) + .pipe( + mergeMap(recurringOrder => [recurringOrdersApiActions.updateRecurringOrderSuccess({ recurringOrder })]), + mapErrorToAction(recurringOrdersApiActions.updateRecurringOrderFail) + ); + } else { + return [recurringOrdersApiActions.updateRecurringOrderSuccess({ recurringOrder })]; + } + }) + ) + ); + + deleteRecurringOrder$ = createEffect(() => + this.actions$.pipe( + ofType(recurringOrdersActions.deleteRecurringOrder), + mapToPayloadProperty('recurringOrderId'), + concatLatestFrom(() => this.store.pipe(select(selectQueryParam('context')))), + mergeMap(([recurringOrderId, context]) => + this.recurringOrdersService.deleteRecurringOrder(recurringOrderId, context).pipe( + mergeMap(() => [ + recurringOrdersApiActions.deleteRecurringOrderSuccess({ recurringOrderId }), + displaySuccessMessage({ + message: 'account.recurring_order.delete.confirmation', + }), + ]), + mapErrorToAction(recurringOrdersApiActions.deleteRecurringOrderFail) + ) + ) + ) + ); + + setRecurringOrderBreadcrumb$ = createEffect(() => + this.actions$.pipe( + ofType(routerNavigatedAction), + switchMap(() => + this.store.pipe( + ofUrl(/^\/account\/recurring-orders\/.*/), + select(getSelectedRecurringOrder), + whenTruthy(), + concatLatestFrom(() => this.store.pipe(select(selectQueryParam('context')))), + map(([recurringOrder, context]) => + setBreadcrumbData({ + breadcrumbData: [ + { + key: 'account.recurring_orders.breadcrumb', + link: '/account/recurring-orders', + linkParams: { context }, + }, + { + text: `${this.translateService.instant('account.recurring_order.details.breadcrumb')} - ${ + recurringOrder.documentNo + }`, + }, + ], + }) + ) + ) + ) + ) + ); +} diff --git a/src/app/core/store/customer/recurring-orders/recurring-orders.reducer.ts b/src/app/core/store/customer/recurring-orders/recurring-orders.reducer.ts new file mode 100644 index 0000000000..0bb8ca8c16 --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/recurring-orders.reducer.ts @@ -0,0 +1,59 @@ +import { EntityState, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; + +import { recurringOrdersActions, recurringOrdersApiActions } from './recurring-orders.actions'; + +export const recurringOrdersAdapter = createEntityAdapter(); + +export interface RecurringOrdersState extends EntityState { + loading: boolean; + error: HttpError; + contexts: { [key: string]: string[] }; +} + +export const initialState: RecurringOrdersState = recurringOrdersAdapter.getInitialState({ + loading: false, + error: undefined, + contexts: {}, +}); + +export const recurringOrdersReducer = createReducer( + initialState, + setLoadingOn(recurringOrdersActions.loadRecurringOrders, recurringOrdersActions.deleteRecurringOrder), + setErrorOn(recurringOrdersApiActions.loadRecurringOrdersFail, recurringOrdersApiActions.deleteRecurringOrderFail), + unsetLoadingAndErrorOn( + recurringOrdersApiActions.loadRecurringOrdersSuccess, + recurringOrdersApiActions.deleteRecurringOrderSuccess + ), + on(recurringOrdersApiActions.loadRecurringOrdersSuccess, (state, action) => { + const { recurringOrders } = action.payload; + return recurringOrdersAdapter.upsertMany(recurringOrders, { + ...state, + contexts: { + ...state.contexts, + [action.payload.context || 'MY']: recurringOrders.map(recurringOrder => recurringOrder.id), + }, + }); + }), + on( + recurringOrdersApiActions.loadRecurringOrderSuccess, + recurringOrdersApiActions.updateRecurringOrderSuccess, + (state, action) => recurringOrdersAdapter.upsertOne(action.payload.recurringOrder, state) + ), + on(recurringOrdersApiActions.deleteRecurringOrderSuccess, (state, action) => { + const { recurringOrderId } = action.payload; + return recurringOrdersAdapter.removeOne(recurringOrderId, { + ...state, + contexts: { + ...Object.entries(state.contexts).reduce( + (acc, [key, value]) => ({ ...acc, [key]: value.filter(id => id !== recurringOrderId) }), + {} + ), + }, + }); + }) +); diff --git a/src/app/core/store/customer/recurring-orders/recurring-orders.selectors.spec.ts b/src/app/core/store/customer/recurring-orders/recurring-orders.selectors.spec.ts new file mode 100644 index 0000000000..5f3d669c1f --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/recurring-orders.selectors.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; + +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; +import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; + +import { recurringOrdersActions } from './recurring-orders.actions'; +import { getRecurringOrdersError, getRecurringOrdersLoading } from './recurring-orders.selectors'; + +describe('Recurring Orders Selectors', () => { + let store$: StoreWithSnapshots; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), CustomerStoreModule.forTesting('recurringOrders')], + providers: [provideStoreSnapshots()], + }); + + store$ = TestBed.inject(StoreWithSnapshots); + }); + + describe('initial state', () => { + it('should not be loading when in initial state', () => { + expect(getRecurringOrdersLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when in initial state', () => { + expect(getRecurringOrdersError(store$.state)).toBeUndefined(); + }); + }); + + describe('loadRecurringOrders', () => { + beforeEach(() => { + store$.dispatch(recurringOrdersActions.loadRecurringOrders({ context: 'MY' })); + }); + + it('should set loading to true', () => { + expect(getRecurringOrdersLoading(store$.state)).toBeTrue(); + }); + }); +}); diff --git a/src/app/core/store/customer/recurring-orders/recurring-orders.selectors.ts b/src/app/core/store/customer/recurring-orders/recurring-orders.selectors.ts new file mode 100644 index 0000000000..02ea024ea2 --- /dev/null +++ b/src/app/core/store/customer/recurring-orders/recurring-orders.selectors.ts @@ -0,0 +1,32 @@ +import { createSelector } from '@ngrx/store'; + +import { selectRouteParam } from 'ish-core/store/core/router'; +import { getCustomerState } from 'ish-core/store/customer/customer-store'; + +import { initialState, recurringOrdersAdapter } from './recurring-orders.reducer'; + +const getRecurringOrdersState = createSelector(getCustomerState, state => + state ? state.recurringOrders : initialState +); + +export const getRecurringOrdersLoading = createSelector(getRecurringOrdersState, state => state.loading); + +export const getRecurringOrdersError = createSelector(getRecurringOrdersState, state => state.error); + +const getRecurringOrdersContexts = createSelector(getRecurringOrdersState, state => state.contexts); + +export const { selectEntities, selectAll } = recurringOrdersAdapter.getSelectors(getRecurringOrdersState); + +export const getRecurringOrders = (context: string = 'MY') => + createSelector(selectEntities, getRecurringOrdersContexts, (requisitions, contexts) => + contexts[context]?.map(id => requisitions[id]) + ); + +export const getSelectedRecurringOrder = createSelector( + selectRouteParam('recurringOrderId'), + selectEntities, + (id, entities) => id && entities[id] +); + +export const getRecurringOrder = (recurringOrderId: string) => + createSelector(selectEntities, entities => recurringOrderId && entities[recurringOrderId]); diff --git a/src/app/pages/account-order/account-order/account-order.component.html b/src/app/pages/account-order/account-order/account-order.component.html index af9337c2a2..883eb3c558 100644 --- a/src/app/pages/account-order/account-order/account-order.component.html +++ b/src/app/pages/account-order/account-order/account-order.component.html @@ -38,6 +38,13 @@

{{ 'account.orderdetails.heading.default' | translate }}

{{ order.status }}

+ diff --git a/src/app/pages/account-recurring-order/account-recurring-order-page.component.html b/src/app/pages/account-recurring-order/account-recurring-order-page.component.html new file mode 100644 index 0000000000..b4e00df4c2 --- /dev/null +++ b/src/app/pages/account-recurring-order/account-recurring-order-page.component.html @@ -0,0 +1,200 @@ + + + +

{{ 'account.recurring_order.heading' | translate }}

+ +
+ + +
+

{{ 'account.recurring_order.details.inactive-by-system.message' | translate }}

+

{{ recurringOrder.errorCode }}

+
+ +

+
+
+ +

{{ 'account.recurring_order.subtitle' | translate }}

+ + +
+
+
+
{{ 'account.recurring_order.details.order_number.label' | translate }}
+
{{ recurringOrder.documentNo }}
+
+ +
+
{{ 'account.recurring_order.details.requisition_number.label' | translate }}
+
+ {{ + recurringOrder.documentNo + }}
+ ({{ + 'account.recurring_order.details.approved.text' + | translate + : { + name: approval.approver.firstName + ' ' + approval.approver.lastName, + date: approval.approvalDate | ishDate : 'shortDate' + } + }}, ) +
+
+ +
+
{{ 'account.recurring_order.details.creation_date.label' | translate }}
+
{{ recurringOrder.creationDate | ishDate }}
+
+ +
+
{{ 'account.recurring_order.details.last_order_date.label' | translate }}
+
+ {{ recurringOrder.lastOrderDate ? (recurringOrder.lastOrderDate | ishDate) : '-' }} +
+
+ +
+
{{ 'account.recurring_order.details.next_order_date.label' | translate }}
+
+ {{ recurringOrder.nextOrderDate ? (recurringOrder.nextOrderDate | ishDate) : '-' }} +
+
+ +
+
{{ 'account.recurring_order.details.status.label' | translate }}
+
+ {{ + 'account.recurring_order.details.expired.text' | translate + }} + + + +
+
+
+
+
+
{{ 'account.recurring_order.details.order_count.label' | translate }}
+
{{ recurringOrder.orderCount }}
+
+ +
+
{{ 'account.recurring_order.details.last_placed_orders.label' | translate }}
+
+ +
+
+
+
+ +
+ + + {{ recurringOrder.user.companyName }}
+ + {{ 'account.recurring_order.details.taxationId.label' | translate }} {{ taxationID }}
+
+ {{ recurringOrder.user.firstName }} {{ recurringOrder.user.lastName }}
+ {{ recurringOrder.user.email }}
+
+ + +
+
{{ 'account.recurring_order.details.cost_center.label' | translate }}
+
{{ recurringOrder.costCenterId }} {{ recurringOrder.costCenterName }}
+
+ +
+
+ +
+ + + + + + + + +
+ +
+ + + + + + + +

{{ recurringOrder.payment.displayName }}

+
+
+
+ +

+ + + + +
+
+
+

{{ 'checkout.order_summary.heading' | translate }}

+ +
+
+
+
+ + + diff --git a/src/app/pages/account-recurring-order/account-recurring-order-page.component.spec.ts b/src/app/pages/account-recurring-order/account-recurring-order-page.component.spec.ts new file mode 100644 index 0000000000..c4f4fb81dd --- /dev/null +++ b/src/app/pages/account-recurring-order/account-recurring-order-page.component.spec.ts @@ -0,0 +1,64 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { Customer } from 'ish-core/models/customer/customer.model'; +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { DatePipe } from 'ish-core/pipes/date.pipe'; +import { AddressComponent } from 'ish-shared/components/address/address/address.component'; +import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket-cost-summary/basket-cost-summary.component'; +import { BasketShippingMethodComponent } from 'ish-shared/components/basket/basket-shipping-method/basket-shipping-method.component'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; +import { SwitchComponent } from 'ish-shared/components/common/switch/switch.component'; +import { OrderRecurrenceComponent } from 'ish-shared/components/order/order-recurrence/order-recurrence.component'; + +import { AccountRecurringOrderPageComponent } from './account-recurring-order-page.component'; + +describe('Account Recurring Order Page Component', () => { + let component: AccountRecurringOrderPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let accountFacade: AccountFacade; + + beforeEach(async () => { + accountFacade = mock(AccountFacade); + when(accountFacade.customer$).thenReturn(of({ customerNo: 'OilCorp' } as Customer)); + when(accountFacade.selectedRecurringOrder$).thenReturn( + of({ id: '4711', user: { companyName: 'company' } } as RecurringOrder) + ); + + await TestBed.configureTestingModule({ + declarations: [ + AccountRecurringOrderPageComponent, + MockComponent(AddressComponent), + MockComponent(BasketCostSummaryComponent), + MockComponent(BasketShippingMethodComponent), + MockComponent(FaIconComponent), + MockComponent(InfoBoxComponent), + MockComponent(OrderRecurrenceComponent), + MockComponent(SwitchComponent), + MockDirective(ServerHtmlDirective), + MockPipe(DatePipe), + ], + imports: [TranslateModule.forRoot()], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountRecurringOrderPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/pages/account-recurring-order/account-recurring-order-page.component.ts b/src/app/pages/account-recurring-order/account-recurring-order-page.component.ts new file mode 100644 index 0000000000..7b815e3700 --- /dev/null +++ b/src/app/pages/account-recurring-order/account-recurring-order-page.component.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable, first } from 'rxjs'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { whenTruthy } from 'ish-core/utils/operators'; + +@Component({ + selector: 'ish-account-recurring-order-page', + templateUrl: './account-recurring-order-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountRecurringOrderPageComponent implements OnInit { + recurringOrder$: Observable; + private recurringOrder: RecurringOrder; + taxationID: string; + showErrorCode = false; + + private destroyRef = inject(DestroyRef); + + constructor(private accountFacade: AccountFacade) {} + + ngOnInit() { + this.recurringOrder$ = this.accountFacade.selectedRecurringOrder$; + this.recurringOrder$.pipe(whenTruthy(), first(), takeUntilDestroyed(this.destroyRef)).subscribe(recurringOrder => { + this.recurringOrder = recurringOrder; + }); + + this.accountFacade.customer$ + .pipe(whenTruthy(), first(), takeUntilDestroyed(this.destroyRef)) + .subscribe(customer => { + this.taxationID = this.taxationID || customer?.taxationID; + }); + } + + switchActiveStatus(switchStatus: { active: boolean }) { + this.accountFacade.setActiveRecurringOrder(this.recurringOrder.id, switchStatus.active); + } + + // callback function for ishServerHtml link + get activateRecurringOrder() { + return () => { + this.accountFacade.setActiveRecurringOrder(this.recurringOrder.id, true); + }; + } + + toggleShowErrorCode() { + this.showErrorCode = !this.showErrorCode; + } +} diff --git a/src/app/pages/account-recurring-order/account-recurring-order-page.module.ts b/src/app/pages/account-recurring-order/account-recurring-order-page.module.ts new file mode 100644 index 0000000000..417af0d88e --- /dev/null +++ b/src/app/pages/account-recurring-order/account-recurring-order-page.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { SwitchComponent } from 'ish-shared/components/common/switch/switch.component'; +import { SharedModule } from 'ish-shared/shared.module'; + +import { AccountRecurringOrderPageComponent } from './account-recurring-order-page.component'; + +const accountRecurringOrderPageRoutes: Routes = [{ path: '', component: AccountRecurringOrderPageComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(accountRecurringOrderPageRoutes), SharedModule, SwitchComponent], + declarations: [AccountRecurringOrderPageComponent], +}) +export class AccountRecurringOrderPageModule {} diff --git a/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.html b/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.html new file mode 100644 index 0000000000..43d69ae9bf --- /dev/null +++ b/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.html @@ -0,0 +1,28 @@ +

{{ 'account.recurring_orders.heading' | translate }}

+ +

{{ 'account.recurring_orders.subtitle' | translate }}

+ + + + + +
+ + + +
+ + diff --git a/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.spec.ts b/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.spec.ts new file mode 100644 index 0000000000..e6ff0d38ef --- /dev/null +++ b/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AuthorizationToggleModule } from 'ish-core/authorization-toggle.module'; +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; + +import { AccountRecurringOrdersPageComponent } from './account-recurring-orders-page.component'; +import { RecurringOrderListComponent } from './recurring-order-list/recurring-order-list.component'; + +describe('Account Recurring Orders Page Component', () => { + let component: AccountRecurringOrdersPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let accountFacade: AccountFacade; + + beforeEach(async () => { + accountFacade = mock(AccountFacade); + when(accountFacade.recurringOrdersContext$).thenReturn(of('MY')); + when(accountFacade.recurringOrders$()).thenReturn(of([{ id: '4711' } as RecurringOrder])); + + await TestBed.configureTestingModule({ + imports: [ + AuthorizationToggleModule.forTesting('APP_B2B_MANAGE_ORDERS'), + NgbNavModule, + RouterTestingModule, + TranslateModule.forRoot(), + ], + declarations: [ + AccountRecurringOrdersPageComponent, + MockComponent(ErrorMessageComponent), + MockComponent(RecurringOrderListComponent), + ], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountRecurringOrdersPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.ts b/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.ts new file mode 100644 index 0000000000..a558663216 --- /dev/null +++ b/src/app/pages/account-recurring-orders/account-recurring-orders-page.component.ts @@ -0,0 +1,55 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; + +import { RecurringOrderColumnsType } from './recurring-order-list/recurring-order-list.component'; + +@Component({ + selector: 'ish-account-recurring-orders-page', + templateUrl: './account-recurring-orders-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountRecurringOrdersPageComponent implements OnInit { + recurringOrders$: Observable; + recurringOrdersLoading$: Observable; + recurringOrdersError$: Observable; + columnsToDisplay: RecurringOrderColumnsType[]; + context: string; + + private destroyRef = inject(DestroyRef); + + constructor(private accountFacade: AccountFacade) {} + + ngOnInit() { + this.recurringOrders$ = this.accountFacade.recurringOrders$(); + this.recurringOrdersLoading$ = this.accountFacade.recurringOrdersLoading$; + this.recurringOrdersError$ = this.accountFacade.recurringOrdersError$; + + this.accountFacade.recurringOrdersContext$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(context => { + this.context = context || 'MY'; + context === 'ADMIN' + ? (this.columnsToDisplay = [ + 'recurringOrderNo', + 'frequency', + 'lastOrderDate', + 'nextOrderDate', + 'buyer', + 'orderTotal', + 'actions', + ]) + : (this.columnsToDisplay = [ + 'recurringOrderNo', + 'creationDate', + 'frequency', + 'lastOrderDate', + 'nextOrderDate', + 'orderTotal', + 'actions', + ]); + }); + } +} diff --git a/src/app/pages/account-recurring-orders/account-recurring-orders-page.module.ts b/src/app/pages/account-recurring-orders/account-recurring-orders-page.module.ts new file mode 100644 index 0000000000..eb0dc6a0e5 --- /dev/null +++ b/src/app/pages/account-recurring-orders/account-recurring-orders-page.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { SwitchComponent } from 'ish-shared/components/common/switch/switch.component'; +import { SharedModule } from 'ish-shared/shared.module'; + +import { AccountRecurringOrdersPageComponent } from './account-recurring-orders-page.component'; +import { RecurringOrderListComponent } from './recurring-order-list/recurring-order-list.component'; + +const accountRecurringOrdersPageRoutes: Routes = [ + { path: '', component: AccountRecurringOrdersPageComponent }, + { + path: ':recurringOrderId', + loadChildren: () => + import('../account-recurring-order/account-recurring-order-page.module').then( + m => m.AccountRecurringOrderPageModule + ), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(accountRecurringOrdersPageRoutes), NgbNavModule, SharedModule, SwitchComponent], + declarations: [AccountRecurringOrdersPageComponent, RecurringOrderListComponent], +}) +export class AccountRecurringOrdersPageModule {} diff --git a/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.html b/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.html new file mode 100644 index 0000000000..1f57a2aebf --- /dev/null +++ b/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.html @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'account.recurring_orders.table.id_of_order' | translate }} + + + {{ recurringOrder.documentNo }} + + + {{ 'account.recurring_orders.table.date_of_order' | translate }} + + {{ recurringOrder.creationDate | ishDate }} + + {{ 'account.recurring_orders.table.order_frequency' | translate }} + + {{ + recurringOrder.expired + ? ('account.recurring_orders.expired.text' | translate) + : (recurringOrder.recurrence.interval | ishFrequency) + }} + + {{ 'account.recurring_orders.table.last_order_date' | translate }} + + {{ recurringOrder.lastOrderDate ? (recurringOrder.lastOrderDate | ishDate) : '-' }} + + {{ 'account.recurring_orders.table.next_order_date' | translate }} + + {{ recurringOrder.nextOrderDate ? (recurringOrder.nextOrderDate | ishDate) : '-' }} + + {{ 'account.recurring_orders.table.buyer' | translate }} + + {{ recurringOrder.user.firstName }} {{ recurringOrder.user.lastName }} + + {{ 'account.recurring_orders.table.order_total' | translate }} + + {{ recurringOrder.totals.total | ishPrice : 'gross' }} + + + +
+
+ + +

{{ 'account.recurring_orders.no_placed_orders_message' | translate }}

+
+ + + {{ 'account.recurring_orders.delete.do_you_really.text' | translate }} + diff --git a/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.spec.ts b/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.spec.ts new file mode 100644 index 0000000000..bdcd55b518 --- /dev/null +++ b/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { instance, mock } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +import { RecurringOrderListComponent } from './recurring-order-list.component'; + +describe('Recurring Order List Component', () => { + let component: RecurringOrderListComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let accountFacade: AccountFacade; + + beforeEach(async () => { + accountFacade = mock(AccountFacade); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [MockComponent(ModalDialogComponent), RecurringOrderListComponent], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RecurringOrderListComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.ts b/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.ts new file mode 100644 index 0000000000..b05aac71ae --- /dev/null +++ b/src/app/pages/account-recurring-orders/recurring-order-list/recurring-order-list.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { RecurringOrder } from 'ish-core/models/recurring-order/recurring-order.model'; +import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +export type RecurringOrderColumnsType = + | 'recurringOrderNo' + | 'creationDate' + | 'frequency' + | 'lastOrderDate' + | 'nextOrderDate' + | 'buyer' + | 'orderTotal' + | 'actions'; + +@Component({ + selector: 'ish-recurring-order-list', + templateUrl: './recurring-order-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecurringOrderListComponent { + @Input() recurringOrders: RecurringOrder[]; + @Input() columnsToDisplay: RecurringOrderColumnsType[]; + @Input() context: string; + + constructor(private accountFacade: AccountFacade, private translate: TranslateService) {} + + /** Emits the id of the recurring order to delete. */ + delete(recurringOrderId: string) { + this.accountFacade.deleteRecurringOrder(recurringOrderId); + } + + /** Emits id and active state to update the active state for the recurring order */ + switchActiveStatus(recurringOrder: { active: boolean; id: string }) { + this.accountFacade.setActiveRecurringOrder(recurringOrder.id, recurringOrder.active); + } + + /** Determine the heading of the delete modal and opens the modal. */ + openDeleteConfirmationDialog(recurringOrder: RecurringOrder, modal: ModalDialogComponent) { + modal.options.titleText = this.translate.instant('account.recurring_order.delete_dialog.header', { + 0: recurringOrder.documentNo, + }); + modal.show(recurringOrder.id); + } +} diff --git a/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts b/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts index 9f2effa54b..bca7a60e57 100644 --- a/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts +++ b/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts @@ -9,6 +9,12 @@ export const navigationItems: NavigationItem[] = [ routerLink: '/account/orders', notRole: ['APP_B2B_CXML_USER', 'APP_B2B_OCI_USER'], }, + { + id: 'recurring-orders', + localizationKey: 'account.recurring_orders.navigation.link', + routerLink: '/account/recurring-orders', + serverSetting: 'recurringOrder.enabled', + }, { id: 'wishlists', localizationKey: 'account.wishlists.link', diff --git a/src/app/pages/account/account-navigation/account-navigation.items.ts b/src/app/pages/account/account-navigation/account-navigation.items.ts index 93f0542798..878bccc2e1 100644 --- a/src/app/pages/account/account-navigation/account-navigation.items.ts +++ b/src/app/pages/account/account-navigation/account-navigation.items.ts @@ -14,6 +14,12 @@ export const navigationItems: NavigationItem[] = [ routerLink: '/account/orders', notRole: ['APP_B2B_CXML_USER', 'APP_B2B_OCI_USER'], }, + { + id: 'recurring-orders', + localizationKey: 'account.recurring_orders.navigation.link', + routerLink: '/account/recurring-orders', + serverSetting: 'recurringOrder.enabled', + }, { id: 'requisitions', localizationKey: 'account.requisitions.requisitions', diff --git a/src/app/pages/account/account-page.module.ts b/src/app/pages/account/account-page.module.ts index c5b545a5a2..3ca6403b0b 100644 --- a/src/app/pages/account/account-page.module.ts +++ b/src/app/pages/account/account-page.module.ts @@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; import { authorizationToggleGuard } from 'ish-core/authorization-toggle.module'; import { featureToggleGuard } from 'ish-core/feature-toggle.module'; +import { serverSettingGuard } from 'ish-core/guards/server-setting.guard'; import { SharedModule } from 'ish-shared/shared.module'; import { AccountOverviewPageModule } from '../account-overview/account-overview-page.module'; @@ -100,6 +101,18 @@ const accountPageRoutes: Routes = [ data: { permission: 'APP_B2B_PURCHASE' }, loadChildren: () => import('requisition-management').then(m => m.RequisitionManagementRoutingModule), }, + { + path: 'recurring-orders', + canActivate: [serverSettingGuard], + data: { + serverSetting: 'recurringOrder.enabled', + breadcrumbData: [{ key: 'account.recurring_orders.breadcrumb' }], + }, + loadChildren: () => + import('../account-recurring-orders/account-recurring-orders-page.module').then( + m => m.AccountRecurringOrdersPageModule + ), + }, ], }, ]; diff --git a/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.html b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.html new file mode 100644 index 0000000000..8f5f531234 --- /dev/null +++ b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.html @@ -0,0 +1,35 @@ +
+
+ + +
+
+ + +
+
+
+ + +
+
diff --git a/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.scss b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.scss new file mode 100644 index 0000000000..b52566de33 --- /dev/null +++ b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.scss @@ -0,0 +1,12 @@ +@import 'variables'; + +#order-recurrence { + padding: ($space-default * 0.5) ($space-default * 1.5); + margin: ($space-default * 1) ($space-default * -1.5); + font-size: $font-size-corporate; + background-color: $white; +} + +#order-recurrence-configuration { + height: 100px; +} diff --git a/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.spec.ts b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.spec.ts new file mode 100644 index 0000000000..8fdb5f6766 --- /dev/null +++ b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { instance, mock } from 'ts-mockito'; + +import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; + +import { BasketOrderRecurrenceEditComponent } from './basket-order-recurrence-edit.component'; + +describe('Basket Order Recurrence Edit Component', () => { + let component: BasketOrderRecurrenceEditComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [BasketOrderRecurrenceEditComponent], + providers: [{ provide: CheckoutFacade, useFactory: () => instance(mock(CheckoutFacade)) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BasketOrderRecurrenceEditComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.ts b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.ts new file mode 100644 index 0000000000..3ccd80aafc --- /dev/null +++ b/src/app/pages/basket/basket-order-recurrence-edit/basket-order-recurrence-edit.component.ts @@ -0,0 +1,253 @@ +/* eslint-disable unicorn/no-null */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { UntypedFormGroup } from '@angular/forms'; +import { FormlyFieldConfig } from '@ngx-formly/core'; +import { formatISO, parseISO } from 'date-fns'; +import { isEqual } from 'lodash-es'; +import { debounceTime, distinctUntilChanged, skip } from 'rxjs'; + +import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; + +interface RecurrenceFormData { + period: string; + duration: string; + startDate: Date; + endDate?: Date; + repetitions?: number; + ending: 'date' | 'repetitions'; +} + +@Component({ + selector: 'ish-basket-order-recurrence-edit', + templateUrl: './basket-order-recurrence-edit.component.html', + styleUrls: ['./basket-order-recurrence-edit.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasketOrderRecurrenceEditComponent implements OnChanges, OnInit { + @Input() recurrence: Recurrence; + + // default order recurrence value: weekly recurrence starting today + defaultRecurrence: Recurrence = { + interval: 'P1W', + startDate: formatISO(new Date()), + }; + + private periodOptions = [ + { value: 'D', label: `order.recurrence.period.days` }, + { value: 'W', label: `order.recurrence.period.weeks` }, + { value: 'M', label: `order.recurrence.period.months` }, + { value: 'Y', label: `order.recurrence.period.years` }, + ]; + + form = new UntypedFormGroup({}); + model: RecurrenceFormData; + fields: FormlyFieldConfig[] = [ + { + fieldGroupClassName: 'row', + fieldGroup: [ + { + key: 'duration', + type: 'ish-number-field', + className: 'col-12 col-lg-5', + props: { + label: 'order.recurrence.form.duration.label', + labelClass: 'col-md-12', + fieldClass: 'col-md-12', + min: 1, + }, + }, + { + key: 'period', + type: 'ish-select-field', + className: 'col-12 col-lg-7', + props: { + options: this.periodOptions, + label: 'order.recurrence.form.period.label', + labelClass: 'col-md-12 hidden d-none d-lg-block', + fieldClass: 'col-md-12', + }, + }, + ], + }, + { + key: 'startDate', + type: 'ish-date-picker-field', + props: { + label: 'order.recurrence.form.startDate.label', + minDays: 0, + labelClass: 'col-md-12', + fieldClass: 'col-md-12', + }, + }, + { + fieldGroupClassName: 'row', + fieldGroup: [ + { + key: 'ending', + type: 'ish-radio-field', + className: 'col-12 col-md-3', + props: { + label: 'order.recurrence.form.ending.date.label', + value: 'date', + labelClass: 'col-md-12 pl-0', + fieldClass: 'col-md-12', + }, + }, + { + key: 'endDate', + type: 'ish-date-picker-field', + className: 'col-12 col-md-9', + props: { + placeholder: 'mm/dd/yy', + labelClass: 'col-md-12', + fieldClass: 'col-md-12', + }, + expressions: { + 'props.disabled': 'model.ending === "repetitions"', + 'props.minDays': field => this.calculateMinimumEndDate(field.model.startDate), + 'model.endDate': 'model.repetitions || model.ending === "repetitions" ? null : model.endDate', + }, + }, + ], + }, + { + fieldGroupClassName: 'row', + fieldGroup: [ + { + key: 'ending', + type: 'ish-radio-field', + className: 'col-12 col-md-3 col-lg-5 mr-lg-0', + props: { + label: 'order.recurrence.form.ending.repetitions.label', + value: 'repetitions', + labelClass: 'col-md-12 pl-0', + fieldClass: 'col-md-12', + }, + }, + { + key: 'repetitions', + type: 'ish-number-field', + className: 'col-5 col-md-4 col-lg-3 pl-md-3 pl-lg-0 pr-md-0', + props: { + labelClass: 'col-md-12', + fieldClass: 'col-md-12', + inputClass: 'testClass', + min: 1, + }, + expressions: { + 'props.disabled': 'model.ending !== "repetitions"', + 'model.repetitions': + 'model.endDate || model.ending === "date" ? null : model.repetitions ? model.repetitions : 50', + }, + }, + { + type: 'ish-information-field', + className: 'col-7 col-md-5 col-lg-4 pl-0 pt-2 pt-md-0', + props: { + containerClass: 'p-md-2', + localizationKey: 'order.recurrence.form.info.text', + }, + }, + ], + }, + ]; + + private destroyRef = inject(DestroyRef); + + constructor(private checkoutFacade: CheckoutFacade) {} + + ngOnInit(): void { + // save changes after form values changed and an update is necessary + this.form.valueChanges + .pipe(skip(1), debounceTime(1000), distinctUntilChanged(isEqual), takeUntilDestroyed(this.destroyRef)) + .subscribe(data => { + if (this.formDataDifferentToRecurrence(data)) { + this.updateOrderRecurrence(this.mapFormDataToRecurrence(data)); + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!isEqual(changes.recurrence.currentValue, changes.recurrence.previousValue)) { + this.model = this.getModel(this.recurrence); + } + } + + updateOrderRecurrence(updateData: Recurrence | null) { + if (updateData) { + // update order recurrence with form values (or default value) + this.checkoutFacade.updateBasketRecurrence(updateData); + } else { + // remove order recurrence + this.checkoutFacade.updateBasketRecurrence(null); + } + } + + private getModel(recurrence: Recurrence): RecurrenceFormData { + if (!recurrence) { + return; + } + let period = recurrence.interval.slice(-1).toUpperCase(); + let duration = parseInt(recurrence.interval.slice(1, -1), 10); + // convert days to weeks if possible since the API only returns daily, monthly or yearly intervals + if (period === 'D' && duration % 7 === 0) { + period = 'W'; + duration = duration / 7; + } + return { + period, + duration: duration.toString(), + startDate: parseISO(recurrence.startDate), + endDate: recurrence.endDate ? parseISO(recurrence.endDate) : undefined, + repetitions: recurrence.repetitions, + ending: recurrence.repetitions ? 'repetitions' : 'date', + }; + } + + private mapFormDataToRecurrence(data: RecurrenceFormData): Recurrence { + if (!data) { + return; + } + return { + interval: `P${data.duration}${data.period}`, + startDate: formatISO(data.startDate), + endDate: data.endDate ? formatISO(data.endDate) : null, + repetitions: data.repetitions ? data.repetitions : null, + }; + } + + private formDataDifferentToRecurrence(data: RecurrenceFormData): boolean { + if (!data) { + return false; + } + const recurrence = this.mapFormDataToRecurrence(data); + if (recurrence.interval.endsWith('W')) { + recurrence.interval = `P${parseInt(recurrence.interval.slice(1, -1), 10) * 7}D`; + } + return ( + this.recurrence.interval !== recurrence.interval || + this.recurrence.startDate.slice(0, 10) !== recurrence.startDate.slice(0, 10) || + this.recurrence.endDate?.slice(0, 10) !== recurrence.endDate?.slice(0, 10) || + // eslint-disable-next-line eqeqeq + this.recurrence.repetitions != recurrence.repetitions + ); + } + + private calculateMinimumEndDate(startDate: string): number { + const date1 = new Date(startDate); + const date2 = new Date(); + const difference = date1.getTime() - date2.getTime(); + return Math.ceil(difference / (1000 * 3600 * 24)) || 1; + } +} diff --git a/src/app/pages/basket/basket-page.module.ts b/src/app/pages/basket/basket-page.module.ts index 7076a53d50..b87fce1d35 100644 --- a/src/app/pages/basket/basket-page.module.ts +++ b/src/app/pages/basket/basket-page.module.ts @@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; import { SharedModule } from 'ish-shared/shared.module'; +import { BasketOrderRecurrenceEditComponent } from './basket-order-recurrence-edit/basket-order-recurrence-edit.component'; import { BasketPageComponent } from './basket-page.component'; import { ShoppingBasketEmptyComponent } from './shopping-basket-empty/shopping-basket-empty.component'; import { ShoppingBasketPaymentComponent } from './shopping-basket-payment/shopping-basket-payment.component'; @@ -13,6 +14,7 @@ const basketPageRoutes: Routes = [{ path: '', component: BasketPageComponent }]; @NgModule({ imports: [RouterModule.forChild(basketPageRoutes), SharedModule], declarations: [ + BasketOrderRecurrenceEditComponent, BasketPageComponent, ShoppingBasketComponent, ShoppingBasketEmptyComponent, diff --git a/src/app/pages/basket/shopping-basket-payment/shopping-basket-payment.component.html b/src/app/pages/basket/shopping-basket-payment/shopping-basket-payment.component.html index 36728a80e5..39291b95a6 100644 --- a/src/app/pages/basket/shopping-basket-payment/shopping-basket-payment.component.html +++ b/src/app/pages/basket/shopping-basket-payment/shopping-basket-payment.component.html @@ -12,21 +12,22 @@ -
  • {{ 'shopping_cart.payment.or.text' | translate }}
  • -
  • - - - -
  • -
    + +
  • {{ 'shopping_cart.payment.or.text' | translate }}
  • +
  • + + + +
  • diff --git a/src/app/pages/basket/shopping-basket/shopping-basket.component.html b/src/app/pages/basket/shopping-basket/shopping-basket.component.html index 516f35f64b..1d40375826 100644 --- a/src/app/pages/basket/shopping-basket/shopping-basket.component.html +++ b/src/app/pages/basket/shopping-basket/shopping-basket.component.html @@ -88,6 +88,11 @@

    {{ 'checkout.order_details.heading' | translate }}

    + + diff --git a/src/app/pages/checkout-shipping/checkout-shipping-page.component.html b/src/app/pages/checkout-shipping/checkout-shipping-page.component.html index 1f20d976db..1ed25f21d9 100644 --- a/src/app/pages/checkout-shipping/checkout-shipping-page.component.html +++ b/src/app/pages/checkout-shipping/checkout-shipping-page.component.html @@ -42,7 +42,11 @@

    {{ 'checkout.shipping_method.selection.heading' | translate }}

    {{ 'checkout.order_details.heading' | translate }}

    + + + +
    diff --git a/src/app/pages/checkout-shipping/checkout-shipping-page.component.spec.ts b/src/app/pages/checkout-shipping/checkout-shipping-page.component.spec.ts index 46b0937c7a..b7d0b354ea 100644 --- a/src/app/pages/checkout-shipping/checkout-shipping-page.component.spec.ts +++ b/src/app/pages/checkout-shipping/checkout-shipping-page.component.spec.ts @@ -15,6 +15,7 @@ import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket- import { BasketErrorMessageComponent } from 'ish-shared/components/basket/basket-error-message/basket-error-message.component'; import { BasketItemsSummaryComponent } from 'ish-shared/components/basket/basket-items-summary/basket-items-summary.component'; import { BasketMerchantMessageComponent } from 'ish-shared/components/basket/basket-merchant-message/basket-merchant-message.component'; +import { BasketRecurrenceSummaryComponent } from 'ish-shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component'; import { BasketValidationResultsComponent } from 'ish-shared/components/basket/basket-validation-results/basket-validation-results.component'; import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; @@ -37,6 +38,7 @@ describe('Checkout Shipping Page Component', () => { MockComponent(BasketErrorMessageComponent), MockComponent(BasketItemsSummaryComponent), MockComponent(BasketMerchantMessageComponent), + MockComponent(BasketRecurrenceSummaryComponent), MockComponent(BasketValidationResultsComponent), MockComponent(CheckoutShippingComponent), MockComponent(ErrorMessageComponent), diff --git a/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html b/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html index 0d8f287f08..751ea9464d 100644 --- a/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html +++ b/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html @@ -15,6 +15,7 @@
  • {{ 'approval.details.conditions.order_spend_limit' | translate }}
  • {{ 'approval.details.conditions.budget_limit' | translate }}
  • {{ 'approval.details.conditions.cost_center' | translate }}
  • +
  • {{ 'approval.details.conditions.recurring_order' | translate }}
  • {{ 'approval.details.place_order' | translate }} diff --git a/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.html b/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.html index 25c0b5d2b0..685560e189 100644 --- a/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.html +++ b/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.html @@ -1,21 +1,23 @@ -

    -

    {{ 'checkout.desired_delivery_date.title' | translate }}

    + +
    +

    {{ 'checkout.desired_delivery_date.title' | translate }}

    -
    -
    -
    - -
    + +
    +
    + +
    -
    - -
    +
    + +
    -
    - +
    + +
    -
    - -
    + +
    +
    diff --git a/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.spec.ts b/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.spec.ts index 0e35cdf26e..dac8f2096a 100644 --- a/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.spec.ts +++ b/src/app/shared/components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component.spec.ts @@ -30,6 +30,10 @@ describe('Basket Desired Delivery Date Component', () => { component = fixture.componentInstance; element = fixture.nativeElement; + component.basket = { + attributes: [{ name: 'desiredDeliveryDate', value: '2022-03-17' }], + } as Basket; + when(checkoutFacade.setDesiredDeliveryDate(anything())).thenReturn(); }); @@ -41,15 +45,19 @@ describe('Basket Desired Delivery Date Component', () => { it('should display desired delivery date input fields on form', () => { fixture.detectChanges(); - expect(element.innerHTML).toContain('desiredDeliveryDate'); }); - it('should show current desired delivery date from the store', () => { + it('should not display desired delivery date input fields for recurring order', () => { component.basket = { + recurrence: { interval: 'P7M' }, attributes: [{ name: 'desiredDeliveryDate', value: '2022-03-17' }], } as Basket; + fixture.detectChanges(); + expect(element.innerHTML).not.toContain('desiredDeliveryDate'); + }); + it('should show current desired delivery date from the store', () => { fixture.detectChanges(); component.ngOnChanges({ basket: new SimpleChange(undefined, component.basket, false) }); @@ -57,12 +65,7 @@ describe('Basket Desired Delivery Date Component', () => { }); it('should call setDesiredDeliveryDate if submit form is called', () => { - component.basket = { - attributes: [{ name: 'desiredDeliveryDate', value: '2022-03-17' }], - } as Basket; - fixture.detectChanges(); - component.submitForm(); verify(checkoutFacade.setDesiredDeliveryDate(anything())).once(); }); diff --git a/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.html b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.html new file mode 100644 index 0000000000..5dd0e08063 --- /dev/null +++ b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.html @@ -0,0 +1,5 @@ +
    +
    {{ 'order.recurrence.heading' | translate }}
    + + +
    diff --git a/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.scss b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.scss new file mode 100644 index 0000000000..9945313248 --- /dev/null +++ b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.scss @@ -0,0 +1,5 @@ +@import 'variables'; + +:host ::ng-deep dl { + font-size: $font-size-sm; +} diff --git a/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.spec.ts b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.spec.ts new file mode 100644 index 0000000000..e40b73b908 --- /dev/null +++ b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BasketRecurrenceSummaryComponent } from './basket-recurrence-summary.component'; + +describe('Basket Recurrence Summary Component', () => { + let component: BasketRecurrenceSummaryComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BasketRecurrenceSummaryComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BasketRecurrenceSummaryComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.ts b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.ts new file mode 100644 index 0000000000..53806f2822 --- /dev/null +++ b/src/app/shared/components/basket/basket-recurrence-summary/basket-recurrence-summary.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; + +@Component({ + selector: 'ish-basket-recurrence-summary', + templateUrl: './basket-recurrence-summary.component.html', + styleUrls: ['./basket-recurrence-summary.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasketRecurrenceSummaryComponent { + @Input() recurrence: Recurrence; +} diff --git a/src/app/shared/components/common/breadcrumb/breadcrumb.component.html b/src/app/shared/components/common/breadcrumb/breadcrumb.component.html index ef4ee749fd..7b5be1be7b 100644 --- a/src/app/shared/components/common/breadcrumb/breadcrumb.component.html +++ b/src/app/shared/components/common/breadcrumb/breadcrumb.component.html @@ -22,9 +22,13 @@ class="breadcrumbs-list" [ngClass]="{ 'breadcrumbs-list-active': last }" > - {{ - item.text || (item.key | translate) - }} + {{ item.text || (item.key | translate) }}
    {{ item.text || (item.key | translate) }}
    diff --git a/src/app/shared/components/order/order-list/order-list.component.html b/src/app/shared/components/order/order-list/order-list.component.html index c1f31475f1..763287a464 100644 --- a/src/app/shared/components/order/order-list/order-list.component.html +++ b/src/app/shared/components/order/order-list/order-list.component.html @@ -8,6 +8,12 @@ {{ order.creationDate | ishDate }} + diff --git a/src/app/shared/components/order/order-recurrence/order-recurrence.component.html b/src/app/shared/components/order/order-recurrence/order-recurrence.component.html new file mode 100644 index 0000000000..9e2f6f9bf9 --- /dev/null +++ b/src/app/shared/components/order/order-recurrence/order-recurrence.component.html @@ -0,0 +1,16 @@ + +
    +
    {{ 'order.recurrence.interval.label' | translate }}
    +
    {{ recurrence.interval | ishFrequency }}
    +
    {{ 'order.recurrence.start.label' | translate }}
    +
    {{ recurrence.startDate | ishDate }}
    + +
    {{ 'order.recurrence.end.label' | translate }}
    +
    {{ recurrence.endDate | ishDate }}
    +
    + +
    {{ 'order.recurrence.repetitions.label' | translate }}
    +
    after {{ recurrence.repetitions }} orders
    +
    +
    +
    diff --git a/src/app/shared/components/order/order-recurrence/order-recurrence.component.scss b/src/app/shared/components/order/order-recurrence/order-recurrence.component.scss new file mode 100644 index 0000000000..b87ba01d73 --- /dev/null +++ b/src/app/shared/components/order/order-recurrence/order-recurrence.component.scss @@ -0,0 +1,9 @@ +@import 'variables'; + +.dl-horizontal { + margin-bottom: 0; + + dd { + margin-bottom: 0; + } +} diff --git a/src/app/shared/components/order/order-recurrence/order-recurrence.component.spec.ts b/src/app/shared/components/order/order-recurrence/order-recurrence.component.spec.ts new file mode 100644 index 0000000000..cc1369d550 --- /dev/null +++ b/src/app/shared/components/order/order-recurrence/order-recurrence.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OrderRecurrenceComponent } from './order-recurrence.component'; + +describe('Order Recurrence Component', () => { + let component: OrderRecurrenceComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OrderRecurrenceComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OrderRecurrenceComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/shared/components/order/order-recurrence/order-recurrence.component.ts b/src/app/shared/components/order/order-recurrence/order-recurrence.component.ts new file mode 100644 index 0000000000..86d8c845ed --- /dev/null +++ b/src/app/shared/components/order/order-recurrence/order-recurrence.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { Recurrence } from 'ish-core/models/recurrence/recurrence.model'; + +@Component({ + selector: 'ish-order-recurrence', + templateUrl: './order-recurrence.component.html', + styleUrls: ['./order-recurrence.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OrderRecurrenceComponent { + @Input() recurrence: Recurrence; + @Input() labelCssClass: string; + @Input() valueCssClass: string; +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 45820d2c42..163b4e012b 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -78,6 +78,7 @@ import { BasketOrderReferenceComponent } from './components/basket/basket-order- import { BasketPaymentCostInfoComponent } from './components/basket/basket-payment-cost-info/basket-payment-cost-info.component'; import { BasketPromotionCodeComponent } from './components/basket/basket-promotion-code/basket-promotion-code.component'; import { BasketPromotionComponent } from './components/basket/basket-promotion/basket-promotion.component'; +import { BasketRecurrenceSummaryComponent } from './components/basket/basket-recurrence-summary/basket-recurrence-summary.component'; import { BasketShippingMethodComponent } from './components/basket/basket-shipping-method/basket-shipping-method.component'; import { BasketValidationItemsComponent } from './components/basket/basket-validation-items/basket-validation-items.component'; import { BasketValidationProductsComponent } from './components/basket/basket-validation-products/basket-validation-products.component'; @@ -117,6 +118,7 @@ import { IdentityProviderLoginComponent } from './components/login/identity-prov import { LoginFormComponent } from './components/login/login-form/login-form.component'; import { LoginModalComponent } from './components/login/login-modal/login-modal.component'; import { OrderListComponent } from './components/order/order-list/order-list.component'; +import { OrderRecurrenceComponent } from './components/order/order-recurrence/order-recurrence.component'; import { OrderWidgetComponent } from './components/order/order-widget/order-widget.component'; import { ProductAddToBasketComponent } from './components/product/product-add-to-basket/product-add-to-basket.component'; import { ProductAttachmentsComponent } from './components/product/product-attachments/product-attachments.component'; @@ -260,6 +262,7 @@ const exportedComponents = [ BasketItemsSummaryComponent, BasketMerchantMessageComponent, BasketMerchantMessageViewComponent, + BasketRecurrenceSummaryComponent, BasketOrderReferenceComponent, BasketPaymentCostInfoComponent, BasketPromotionCodeComponent, @@ -288,6 +291,7 @@ const exportedComponents = [ ModalDialogComponent, ModalDialogLinkComponent, OrderListComponent, + OrderRecurrenceComponent, OrderWidgetComponent, ProductAddToBasketComponent, ProductAttachmentsComponent, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index 5e79253df0..8fce63f61e 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -62,6 +62,7 @@ "account.approvallist.table.date_of_order": "Erstellungsdatum", "account.approvallist.table.id_of_order": "Bestellanfrage-Nr.", "account.approvallist.table.id_of_order.aria_label": "Zu den Bestellanfragendetails navigieren", + "account.approvallist.table.id_of_order.recurring": "Mit dieser Bestellanforderung wurde eine wiederkehrende Bestellung ausgelöst.", "account.approvallist.table.line_item_total": "Gesamt", "account.approvallist.table.line_items": "Artikel", "account.approvallist.table.no_of_order": "Bestellnummer", @@ -281,6 +282,7 @@ "account.orderdetails.order_number.label": "Bestellnr.", "account.orderdetails.order_status.label": "Bestellstatus", "account.orderdetails.print_link.text": "Drucken", + "account.orderdetails.recurring_order.info": "Diese Bestellung wurde auf Grundlage einer wiederkehrenden Bestellung erzeugt.", "account.orderlist.no_orders_message": "Derzeit sind keine Bestellungen vorhanden.", "account.orderlist.no_placed_orders_message": "Sie haben noch keine Bestellung aufgegeben.", "account.orderlist.table.buyer": "Einkäufer", @@ -290,6 +292,7 @@ "account.orderlist.table.order_number": "Bestellnr.", "account.orderlist.table.order_status": "Status", "account.orderlist.table.order_total": "Gesamtsumme", + "account.orderlist.table.recurring.icon.title": "Wiederkehrende Bestellung", "account.ordertemplates.heading": "Bestellvorlagen", "account.ordertemplates.link": "Bestellvorlagen", "account.ordertemplates.widget.heading": "Meine Bestellvorlagen", @@ -420,6 +423,55 @@ "account.quotes.widget.responded.label": "Beantwortete Preisangebote", "account.quotes.widget.submitted.label": "Gesendete Anfragen", "account.quotes.widget.view_all_quotes.link": "Alle Preisangebote anzeigen", + "account.recurring_order.delete.confirmation": "Ihre wiederkehrende Bestellung wurde gelöscht.", + "account.recurring_order.delete_dialog.header": "Wiederkehrende Bestellung „{{0}}“ löschen", + "account.recurring_order.details.active.text": "Aktiv", + "account.recurring_order.details.additional_info.heading": "Zusätzliche Informationen", + "account.recurring_order.details.approved.text": "genehmigt von {{name}} am {{date}}", + "account.recurring_order.details.billing_address.heading": "Rechnungsadresse", + "account.recurring_order.details.breadcrumb": "Wiederkehrende Bestellung", + "account.recurring_order.details.buyer_info.heading": "Einkäufer", + "account.recurring_order.details.cost_center.label": "Kostenstelle", + "account.recurring_order.details.creation_date.label": "Erstellungsdatum", + "account.recurring_order.details.expired.text": "Beendet", + "account.recurring_order.details.inactive-by-system.message": "Die letzte planmäßige Bestellung konnte nicht aufgegeben werden und die wiederkehrende Bestellung wurde deaktiviert.", + "account.recurring_order.details.inactive.message": "Ihre wiederkehrende Bestellung ist derzeit inaktiv. Aktivieren Sie sie jetzt, um weiterhin regelmäßige Lieferungen und exklusive Rabatte zu erhalten.", + "account.recurring_order.details.inactive.text": "Inaktiv", + "account.recurring_order.details.info_message": "Bitte beachten Sie: Bestellungen werden auch ausgeführt, wenn sich Preise oder Verfügbarkeit der enthaltenen Artikel geändert haben. Details finden Sie in der Bestellbestätigung.", + "account.recurring_order.details.last_order_date.label": "Letzte Bestellung", + "account.recurring_order.details.last_placed_orders.label": "Letzte 5 Bestellungen", + "account.recurring_order.details.links.return_to_orders": "Zurück zu wiederkehrenden Bestellungen", + "account.recurring_order.details.next_order_date.label": "Nächste Bestellung", + "account.recurring_order.details.order_count.label": "Anzahl Bestellungen", + "account.recurring_order.details.order_number.label": "Wiederkehrende Bestellung Nr.", + "account.recurring_order.details.payment_method.heading": "Zahlungsart", + "account.recurring_order.details.print_link.text": "Drucken", + "account.recurring_order.details.requisition_number.label": "Bestellanfrage-Nr.", + "account.recurring_order.details.shipping_address.heading": "Lieferadresse", + "account.recurring_order.details.shipping_method.heading": "Versandart", + "account.recurring_order.details.status.label": "Status", + "account.recurring_order.details.taxationId.label": "Steuernummer:", + "account.recurring_order.heading": "Details zur wiederkehrenden Bestellung", + "account.recurring_order.subtitle": "Nachfolgend finden Sie Details zu den Artikeln in Ihrer wiederkehrenden Bestellung. Wenn Sie mehr als einen Artikel bestellt haben, beachten Sie, dass einige Artikel eine andere Versandart und/oder Bestellstatus haben können, weil sie einzeln versandt werden.", + "account.recurring_orders.breadcrumb": "Wiederkehrende Bestellung", + "account.recurring_orders.delete.button.text": "Löschen", + "account.recurring_orders.delete.do_you_really.text": "Wollen Sie diese wiederkehrende Bestellung wirklich löschen?", + "account.recurring_orders.expired.text": "Beendet", + "account.recurring_orders.heading": "Wiederkehrende Bestellungen", + "account.recurring_orders.link.title.remove": "Wiederkehrende Bestellung „{{0}}“ löschen", + "account.recurring_orders.navigation.link": "Wiederkehrende Bestellungen", + "account.recurring_orders.no_placed_orders_message": "Sie haben noch keine wiederkehrende Bestellung aufgegeben.", + "account.recurring_orders.subtitle": "Ihre letzte wiederkehrende Bestellung erscheint zuerst. Bitte gedulden Sie sich bis zu fünf Minuten, bis alle neueren Bestellungen unten erscheinen.", + "account.recurring_orders.tab_all": "Alle wiederkehrenden Bestellungen", + "account.recurring_orders.tab_my": "Meine wiederkehrenden Bestellungen", + "account.recurring_orders.table.buyer": "Einkäufer", + "account.recurring_orders.table.date_of_order": "Erstellungsdatum", + "account.recurring_orders.table.id_of_order": "Nr.", + "account.recurring_orders.table.id_of_order.aria_label": "Zu den Bestelldetails navigieren", + "account.recurring_orders.table.last_order_date": "Letzte Bestellung", + "account.recurring_orders.table.next_order_date": "Nächste Bestellung", + "account.recurring_orders.table.order_frequency": "Turnus", + "account.recurring_orders.table.order_total": "Gesamt", "account.register.address.headding": "Adresse", "account.register.address.message": "Um bei der Bestellung Zeit zu sparen, geben Sie unten Ihre Hauptadressen für Rechnung und Lieferung an. Die Informationen werden gespeichert, sodass Sie sie bei zukünftigen Bestellungen nicht mehr jedes Mal erneut eingeben müssen.", "account.register.company_information.heading": "Firmeninformationen", @@ -623,9 +675,10 @@ "approval.details.conditions.budget_limit": "Die Bestellanfrage überschreitet Ihr Budget.", "approval.details.conditions.cost_center": "Die Bestellanfrage ist einer Kostenstelle zugeordnet.", "approval.details.conditions.order_spend_limit": "Die Bestellanfrage überschreitet Ihr Ausgabelimit pro Bestellung.", + "approval.details.conditions.recurring_order": "Die Bestellanforderung löst eine wiederkehrende Bestellung aus.", "approval.details.contacts.heading": "Kontaktdaten der Genehmiger", "approval.details.cost_center_approvers.people_allowed": "Die folgende Person darf Ihre Bestellanfrage genehmigen, da sie der Kostenstelle {{0}} zugeordnet ist:", - "approval.details.customer_approvers.people_allowed": "Folgende Personen können Ihre Bestellanfrage bei Überschreitung des Ausgabelimits oder des Budgets genehmigen:", + "approval.details.customer_approvers.people_allowed": "Folgende Personen können Ihre Bestellanfrage genehmigen, wenn das Ausgabenlimit pro Bestellung und/oder die Budgetgrenzen überschritten werden oder eine wiederkehrende Bestellung aufgegeben wird:", "approval.details.heading": "Genehmigungsdetails", "approval.details.place_order": "Geben Sie eine Bestellanfrage auf, die genehmigt werden muss, wird eine E-Mail an alle Personen gesendet, die die Bestellanfrage genehmigen können. Sie werden per E-Mail benachrichtigt, sobald ihre Bestellanfrage genehmigt oder abgelehnt wurde.", "approval.detailspage.approval.heading": "Genehmigungsdetails", @@ -636,7 +689,7 @@ "approval.detailspage.approval_status.system_rejected.status": "Vom System abgelehnt", "approval.detailspage.approve.button.label": "Genehmigen", "approval.detailspage.approver.label": "Genehmiger", - "approval.detailspage.budget.including_order.label": "Einschließlich dieser Bestellung", + "approval.detailspage.budget.including_order.label": "{{recurring, select, =true{Einschließlich der ersten Bestellung}} =false{Einschließlich dieser Bestellung}}}", "approval.detailspage.buyer.approver.label": "Genehmiger (Einkäufer)", "approval.detailspage.buyer.budget.label": "{{0, select, =weekly{Einkäufer-Budget (wöchentlich)} =monthly{Einkäufer-Budget (monatlich)} =quarterly{Einkäufer-Budget (quartalsweise)} =half-yearly{Einkäufer-Budget (halbjährlich)} =yearly{Einkäufer-Budget (jährlich)}}}", "approval.detailspage.buyer.label": "Einkäufer", @@ -651,7 +704,7 @@ "approval.detailspage.order.request_id": "Bestellanfrage-Nr.", "approval.detailspage.order_date.label": "Erstellungsdatum", "approval.detailspage.order_details.heading": "Bestell-Details", - "approval.detailspage.order_reference_id.label": "Bestellnummer", + "approval.detailspage.order_reference_id.label": "{{recurring, select, =true{Wiederkehrende Bestellung Nr.}} =false{Bestellnr.}}}", "approval.detailspage.order_spend_limit.label": "Ausgabelimit pro Bestellung", "approval.detailspage.order_total.label": "Gesamtsumme", "approval.detailspage.reject.button.label": "Ablehnen", @@ -768,6 +821,7 @@ "checkout.hide_all.link": "Artikel ausblenden", "checkout.id_of_order.label": "Ihre Bestellanfragenr.:", "checkout.order.number.label": "Ihre Bestellnummer ist:", + "checkout.order.recurring.number.label": "Die Nummer Ihrer wiederkehrenden Bestellung lautet:", "checkout.order.shipping.label": "Versand", "checkout.order.total_cost.label": "Gesamtkosten", "checkout.orderReferenceId.apply.button.label": "Übernehmen", @@ -778,6 +832,9 @@ "checkout.order_details.heading": "Bestellübersicht", "checkout.order_review.heading.text": "Prüfen Sie die Details Ihrer Bestellung und nehmen Sie, falls notwendig, noch Änderungen vor. Klicken Sie auf „Bestellung absenden“, um den Bestellvorgang abzuschließen.", "checkout.order_review.heading.title": "Ihre Bestelldaten prüfen", + "checkout.order_review.recurring.heading.text": "Prüfen Sie die Details Ihrer wiederkehrenden Bestellung und nehmen Sie, falls notwendig, noch Änderungen vor. Klicken Sie auf „Wiederkehrende Bestellung absenden“, um den Bestellvorgang abzuschließen.", + "checkout.order_review.recurring.heading.title": "Daten Ihrer wiederkehrenden Bestellung prüfen", + "checkout.order_review.recurring.send.button": "Wiederkehrende Bestellung absenden", "checkout.order_review.send.button": "Bestellung absenden", "checkout.order_summary.heading": "Bestellübersicht", "checkout.payment.addPayment.link": "Zahlungsmittel hinzufügen", @@ -820,6 +877,7 @@ "checkout.receipt.notification.mail.text": "Wir werden {{0}} per E-Mail über den Status Ihrer Bestellung informieren.", "checkout.receipt.order_pending.message": "Der Bestell-Status bleibt unverändert bis der Zahlungsvorgang abgeschlossen wurde.", "checkout.receipt.print.button.label": "Bestellbestätigung drucken", + "checkout.receipt.recurring.tankyou.message": "Vielen Dank für Ihre wiederkehrende Bestellung", "checkout.receipt.tankyou.message": "Vielen Dank für Ihre Bestellung!", "checkout.restriction.error": "Korrigieren Sie den/die unten angegebenen Fehler", "checkout.safeandsecure.details.title": "Sicheres Einkaufen", @@ -855,6 +913,7 @@ "checkout.termsandconditions.details.title": "Allgemeine Geschäftsbedingungen", "checkout.update.label": "{{0}} bearbeiten", "checkout.variation.edit.button.label": "Bearbeiten", + "checkout.widget.additional-information.heading": "Zusätzliche Informationen", "checkout.widget.billing-address.heading": "Rechnungsadresse", "checkout.widget.buyer.TaxationID": "Steuernummer:", "checkout.widget.buyer.costcenter": "Kostenstelle", @@ -960,6 +1019,23 @@ "navigation.paging.previous_page.label": "Zur vorherigen Seite gehen", "number.decrease.text": "-", "number.increase.text": "+", + "order.recurrence.end.label": "Ende", + "order.recurrence.form.duration.label": "Turnus", + "order.recurrence.form.ending.date.label": "Bis", + "order.recurrence.form.ending.repetitions.label": "Endet nach", + "order.recurrence.form.info.text": "Bestellungen", + "order.recurrence.form.period.label": "Turnus", + "order.recurrence.form.startDate.label": "Beginnt am", + "order.recurrence.heading": "Wiederkehrende Bestellung", + "order.recurrence.interval.label": "Turnus", + "order.recurrence.period.days": "{{0, plural, one{# Tag} other{# Tage}}}", + "order.recurrence.period.months": "{{0, plural, one{# Monat} other{# Monate}}}", + "order.recurrence.period.weeks": "{{0, plural, one{# Woche} other{# Wochen}}}", + "order.recurrence.period.years": "{{0, plural, one{# Jahr} other{# Jahre}}}", + "order.recurrence.recurring_order.label": "Wiederkehrende Bestellung", + "order.recurrence.repetitions.label": "Ende", + "order.recurrence.single_order.label": "Einmaliger Einkauf", + "order.recurrence.start.label": "Beginnt am", "order.tracking.error": "Leider konnte keine Bestellung mit Ihren Daten gefunden werden.", "order_template.create.heading": "Bestellvorlage anlegen", "payment.error.PaymentInstrumentAlreadyExists": "Das Zahlungsmittel konnte nicht angelegt werden. Zahlungsdaten mit den angegebenen Parametern sind bereits vorhanden.", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 62692574ee..8d9b2e621c 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -62,6 +62,7 @@ "account.approvallist.table.date_of_order": "Creation date", "account.approvallist.table.id_of_order": "Requisition no.", "account.approvallist.table.id_of_order.aria_label": "Navigate to requisition details", + "account.approvallist.table.id_of_order.recurring": "This requisition initiated a recurring order.", "account.approvallist.table.line_item_total": "Order total", "account.approvallist.table.line_items": "Line items", "account.approvallist.table.no_of_order": "Order no.", @@ -281,6 +282,7 @@ "account.orderdetails.order_number.label": "Order no.", "account.orderdetails.order_status.label": "Order status", "account.orderdetails.print_link.text": "Print", + "account.orderdetails.recurring_order.info": "This order has been created based on a recurring order.", "account.orderlist.no_orders_message": "Currently there are no orders.", "account.orderlist.no_placed_orders_message": "You have not placed an order yet.", "account.orderlist.table.buyer": "Buyer", @@ -290,6 +292,7 @@ "account.orderlist.table.order_number": "Order no.", "account.orderlist.table.order_status": "Status", "account.orderlist.table.order_total": "Order total", + "account.orderlist.table.recurring.icon.title": "Recurring order", "account.ordertemplates.heading": "Order templates", "account.ordertemplates.link": "Order templates", "account.ordertemplates.widget.heading": "My order templates", @@ -420,6 +423,55 @@ "account.quotes.widget.responded.label": "Responded quotes", "account.quotes.widget.submitted.label": "Sent requests", "account.quotes.widget.view_all_quotes.link": "View all quotes", + "account.recurring_order.delete.confirmation": "Your recurring order has been deleted.", + "account.recurring_order.delete_dialog.header": "Delete recurring order \"{{0}}\"", + "account.recurring_order.details.active.text": "Active", + "account.recurring_order.details.additional_info.heading": "Additional information", + "account.recurring_order.details.approved.text": "approved by {{name}} on {{date}}", + "account.recurring_order.details.billing_address.heading": "Invoice address", + "account.recurring_order.details.breadcrumb": "Recurring order", + "account.recurring_order.details.buyer_info.heading": "Buyer", + "account.recurring_order.details.cost_center.label": "Cost center", + "account.recurring_order.details.creation_date.label": "Creation date", + "account.recurring_order.details.expired.text": "Ended", + "account.recurring_order.details.inactive-by-system.message": "The last planned order could not be placed and the recurring order has been deactivated.", + "account.recurring_order.details.inactive.message": "Your recurring order is currently inactive. Activate it now to continue receiving regular deliveries and exclusive discounts.", + "account.recurring_order.details.inactive.text": "Inactive", + "account.recurring_order.details.info_message": "Please note: Orders are created even if prices or the availability of included items have changed. See the order confirmation for details.", + "account.recurring_order.details.last_order_date.label": "Last order", + "account.recurring_order.details.last_placed_orders.label": "Last 5 orders", + "account.recurring_order.details.links.return_to_orders": "Back to recurring orders", + "account.recurring_order.details.next_order_date.label": "Next order", + "account.recurring_order.details.order_count.label": "Order count", + "account.recurring_order.details.order_number.label": "Recurring order no.", + "account.recurring_order.details.payment_method.heading": "Payment method", + "account.recurring_order.details.print_link.text": "Print", + "account.recurring_order.details.requisition_number.label": "Requisition no.", + "account.recurring_order.details.shipping_address.heading": "Shipping address", + "account.recurring_order.details.shipping_method.heading": "Shipping method", + "account.recurring_order.details.status.label": "Status", + "account.recurring_order.details.taxationId.label": "Taxation ID:", + "account.recurring_order.heading": "Recurring order details", + "account.recurring_order.subtitle": "Please find details about the item(s) in your recurring order below. If you ordered more than one item, please note that some items may display a different shipping method and/or status because they are shipped in a separate package.", + "account.recurring_orders.breadcrumb": "Recurring order", + "account.recurring_orders.delete.button.text": "Delete", + "account.recurring_orders.delete.do_you_really.text": "Do you really want to delete this recurring order?", + "account.recurring_orders.expired.text": "Ended", + "account.recurring_orders.heading": "Recurring orders", + "account.recurring_orders.link.title.remove": "Delete recurring order \"{{0}}\"", + "account.recurring_orders.navigation.link": "Recurring orders", + "account.recurring_orders.no_placed_orders_message": "You have not placed a recurring order yet.", + "account.recurring_orders.subtitle": "Your most recent recurring order appears first. It may take up to 5 minutes for new orders to appear below.", + "account.recurring_orders.tab_all": "All recurring orders", + "account.recurring_orders.tab_my": "My recurring orders", + "account.recurring_orders.table.buyer": "Buyer", + "account.recurring_orders.table.date_of_order": "Creation date", + "account.recurring_orders.table.id_of_order": "No.", + "account.recurring_orders.table.id_of_order.aria_label": "Navigate to order details", + "account.recurring_orders.table.last_order_date": "Last order", + "account.recurring_orders.table.next_order_date": "Next order", + "account.recurring_orders.table.order_frequency": "Recur every", + "account.recurring_orders.table.order_total": "Total", "account.register.address.headding": "Address", "account.register.address.message": "To save time when placing an order, please provide your primary invoice or shipping address below. We will store this information so you won’t have to enter it again.", "account.register.company_information.heading": "Company information", @@ -623,9 +675,10 @@ "approval.details.conditions.budget_limit": "The requisition exceeds your budget.", "approval.details.conditions.cost_center": "The requisition is assigned to a cost center.", "approval.details.conditions.order_spend_limit": "The requisition exceeds your spending limit per order.", + "approval.details.conditions.recurring_order": "The requisition initiates a recurring order.", "approval.details.contacts.heading": "Approver contacts", "approval.details.cost_center_approvers.people_allowed": "The following person is allowed to approve your requisition, being assigned to cost center {{0}}:", - "approval.details.customer_approvers.people_allowed": "The following people are allowed to approve your requisition if the spending limit per order and/or budget limits are exceeded:", + "approval.details.customer_approvers.people_allowed": "The following people are allowed to approve your requisition if the spending limit per order and/or budget limits are exceeded or a recurring order is placed:", "approval.details.heading": "Approval details", "approval.details.place_order": "If you place a requisition requiring approval, an e-mail is sent to all people who are allowed to approve the requisition. You will be notified via e-mail once your requisition has been approved or rejected.", "approval.detailspage.approval.heading": "Approval details", @@ -636,7 +689,7 @@ "approval.detailspage.approval_status.system_rejected.status": "Rejected by system", "approval.detailspage.approve.button.label": "Approve", "approval.detailspage.approver.label": "{{0, plural, one{Approver} other{Approvers}}}", - "approval.detailspage.budget.including_order.label": "Including this order", + "approval.detailspage.budget.including_order.label": "{{recurring, select, =true{Including first order} =false{Including this order}}}", "approval.detailspage.buyer.approver.label": "{{0, plural, one{Approver} other{Approvers}}} (Buyer)", "approval.detailspage.buyer.budget.label": "{{0, select, =weekly{Buyer Budget (weekly)} =monthly{Buyer Budget (monthly)} =quarterly{Buyer Budget (quarterly)} =half-yearly{Buyer Budget (half-yearly)} =yearly{Buyer Budget (yearly)}}}", "approval.detailspage.buyer.label": "Buyer", @@ -651,7 +704,7 @@ "approval.detailspage.order.request_id": "Requisition no.", "approval.detailspage.order_date.label": "Creation date", "approval.detailspage.order_details.heading": "Order details", - "approval.detailspage.order_reference_id.label": "Order no.", + "approval.detailspage.order_reference_id.label": "{{recurring, select, =true{Recurring order no.} =false{Order no.}}}", "approval.detailspage.order_spend_limit.label": "Spending limit per order", "approval.detailspage.order_total.label": "Order total", "approval.detailspage.reject.button.label": "Reject", @@ -768,6 +821,7 @@ "checkout.hide_all.link": "Hide items", "checkout.id_of_order.label": "Your requisition no.:", "checkout.order.number.label": "Your order number is:", + "checkout.order.recurring.number.label": "Your recurring order number is:", "checkout.order.shipping.label": "Shipping", "checkout.order.total_cost.label": "Total cost", "checkout.orderReferenceId.apply.button.label": "Apply", @@ -776,8 +830,11 @@ "checkout.orderReferenceId.success.message": "Your customer order ID has been applied.", "checkout.orderReferenceId.title": "Enter a customer order ID", "checkout.order_details.heading": "Order summary", - "checkout.order_review.heading.text": "Review the details of your order below and make any changes if needed. Click \"Submit Order\" to complete your purchase.", + "checkout.order_review.heading.text": "Review the details of your order below and make any changes if needed. Click \"Submit order\" to complete your purchase.", "checkout.order_review.heading.title": "Review your order information", + "checkout.order_review.recurring.heading.text": "Review the details of your recurring order below and make any changes if needed. Click \"Submit recurring order\" to complete your purchase.", + "checkout.order_review.recurring.heading.title": "Review your recurring order information", + "checkout.order_review.recurring.send.button": "Submit recurring order", "checkout.order_review.send.button": "Submit order", "checkout.order_summary.heading": "Order summary", "checkout.payment.addPayment.link": "Add payment instrument", @@ -820,6 +877,7 @@ "checkout.receipt.notification.mail.text": "We will e-mail {{0}} to keep you updated on the status of your order.", "checkout.receipt.order_pending.message": "The order status remains pending until the payment transaction is complete.", "checkout.receipt.print.button.label": "Print receipt", + "checkout.receipt.recurring.tankyou.message": "Thank you for your recurring order", "checkout.receipt.tankyou.message": "Thank you for your order", "checkout.restriction.error": "Please correct the error(s) indicated below", "checkout.safeandsecure.details.title": "Secure shopping", @@ -855,6 +913,7 @@ "checkout.termsandconditions.details.title": "Terms & conditions", "checkout.update.label": "Edit {{0}}", "checkout.variation.edit.button.label": "Edit", + "checkout.widget.additional-information.heading": "Additional information", "checkout.widget.billing-address.heading": "Invoice address", "checkout.widget.buyer.TaxationID": "Taxation ID:", "checkout.widget.buyer.costcenter": "Cost center", @@ -960,6 +1019,23 @@ "navigation.paging.previous_page.label": "Go to previous page", "number.decrease.text": "-", "number.increase.text": "+", + "order.recurrence.end.label": "End", + "order.recurrence.form.duration.label": "Recur every", + "order.recurrence.form.ending.date.label": "Until", + "order.recurrence.form.ending.repetitions.label": "End after", + "order.recurrence.form.info.text": "orders", + "order.recurrence.form.period.label": "Recur every", + "order.recurrence.form.startDate.label": "Start from", + "order.recurrence.heading": "Recurring order", + "order.recurrence.interval.label": "Recur every", + "order.recurrence.period.days": "{{0, plural, one{# Day} other{# Days}}}", + "order.recurrence.period.months": "{{0, plural, one{# Month} other{# Months}}}", + "order.recurrence.period.weeks": "{{0, plural, one{# Week} other{# Weeks}}}", + "order.recurrence.period.years": "{{0, plural, one{# Year} other{# Years}}}", + "order.recurrence.recurring_order.label": "Recurring order", + "order.recurrence.repetitions.label": "End", + "order.recurrence.single_order.label": "One-time purchase", + "order.recurrence.start.label": "Start from", "order.tracking.error": "Unfortunately, we could not locate an order with the information you provided.", "order_template.create.heading": "Create order template", "payment.error.PaymentInstrumentAlreadyExists": "The payment instrument could not be created. Payment data with the given parameters already exists.", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index 1894a72979..e91cfa0209 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -62,6 +62,7 @@ "account.approvallist.table.date_of_order": "Date de création", "account.approvallist.table.id_of_order": "ID de la demande d’achat", "account.approvallist.table.id_of_order.aria_label": "Naviguer vers les détails de la demande d’achat", + "account.approvallist.table.id_of_order.recurring": "Cette demande d’achat a déclenché une commande périodique.", "account.approvallist.table.line_item_total": "Total", "account.approvallist.table.line_items": "Articles", "account.approvallist.table.no_of_order": "ID de la commande", @@ -281,6 +282,7 @@ "account.orderdetails.order_number.label": "N° de commande", "account.orderdetails.order_status.label": "Statut de la commande", "account.orderdetails.print_link.text": "Imprimer", + "account.orderdetails.recurring_order.info": "Cette commande a été créée sur la base d’une commande périodique.", "account.orderlist.no_orders_message": "Actuellement il n’y a aucune commande.", "account.orderlist.no_placed_orders_message": "Vous n’avez pas encore passé de commande.", "account.orderlist.table.buyer": "Acheteur", @@ -290,6 +292,7 @@ "account.orderlist.table.order_number": "N° de la commande", "account.orderlist.table.order_status": "Statut", "account.orderlist.table.order_total": "Total de la commande", + "account.orderlist.table.recurring.icon.title": "Commande périodique", "account.ordertemplates.heading": "Modèles de commande", "account.ordertemplates.link": "Modèles de commande", "account.ordertemplates.widget.heading": "Mes modèles de commande", @@ -420,6 +423,55 @@ "account.quotes.widget.responded.label": "Devis répondus", "account.quotes.widget.submitted.label": "Requêtes envoyées", "account.quotes.widget.view_all_quotes.link": "Afficher tous les devis", + "account.recurring_order.delete.confirmation": "Votre commande périodique a été supprimée.", + "account.recurring_order.delete_dialog.header": "Supprimer la commande périodique « {{0}} »", + "account.recurring_order.details.active.text": "Active", + "account.recurring_order.details.additional_info.heading": "Informations supplémentaires", + "account.recurring_order.details.approved.text": "approuvée par {{name}} le {{date}}", + "account.recurring_order.details.billing_address.heading": "Adresse de facturation", + "account.recurring_order.details.breadcrumb": "Commande périodique", + "account.recurring_order.details.buyer_info.heading": "Acheteur", + "account.recurring_order.details.cost_center.label": "Centre de coûts", + "account.recurring_order.details.creation_date.label": "Date de création", + "account.recurring_order.details.expired.text": "Terminée", + "account.recurring_order.details.inactive-by-system.message": "La dernière commande planifiée n’a pas pu être passée et la commande périodique a été désactivée.", + "account.recurring_order.details.inactive.message": "Votre commande périodique est actuellement inactive. Activez-la dès maintenant pour continuer à recevoir des livraisons régulières et des réductions exclusives.", + "account.recurring_order.details.inactive.text": "Inactive", + "account.recurring_order.details.info_message": "Veuillez noter : Les commandes sont créées même si les prix ou la disponibilité des articles inclus ont changé. Veuillez regarder la confirmation de commande pour plus de détails.", + "account.recurring_order.details.last_order_date.label": "Dernière commande", + "account.recurring_order.details.last_placed_orders.label": "5 dernières commandes", + "account.recurring_order.details.links.return_to_orders": "Retour aux commandes périodiques", + "account.recurring_order.details.next_order_date.label": "Commande suivante", + "account.recurring_order.details.order_count.label": "Nombre de commandes", + "account.recurring_order.details.order_number.label": "N° de la commande périodique", + "account.recurring_order.details.payment_method.heading": "Mode de paiement", + "account.recurring_order.details.print_link.text": "Imprimer", + "account.recurring_order.details.requisition_number.label": "N° de demande d’achat", + "account.recurring_order.details.shipping_address.heading": "Adresse de livraison", + "account.recurring_order.details.shipping_method.heading": "Mode d’expédition", + "account.recurring_order.details.status.label": "Statut", + "account.recurring_order.details.taxationId.label": "ID de taxation :", + "account.recurring_order.heading": "Détails des commandes périodiques", + "account.recurring_order.subtitle": "Vous trouverez ci-dessous des détails sur le(s) article(s) de votre commande périodique. Si vous avez commandé plus d’un article, veuillez noter que certains articles peuvent afficher une méthode d’expédition différente et/ou un statut différent parce qu’ils sont expédiés dans un emballage séparé.", + "account.recurring_orders.breadcrumb": "Commande périodique", + "account.recurring_orders.delete.button.text": "Supprimer", + "account.recurring_orders.delete.do_you_really.text": "Voulez-vous vraiment supprimer cette commande périodique ?", + "account.recurring_orders.expired.text": "Terminée", + "account.recurring_orders.heading": "Commandes périodiques", + "account.recurring_orders.link.title.remove": "Supprimer la commande périodique « {{0}} »", + "account.recurring_orders.navigation.link": "Commandes périodiques", + "account.recurring_orders.no_placed_orders_message": "Vous n’avez pas encore passé de commande périodique.", + "account.recurring_orders.subtitle": "Votre commande périodique la plus récente s’affiche en premier. Veuillez prévoir jusqu’à 5 minutes pour l’apparition des nouvelles commandes ci-dessous.", + "account.recurring_orders.tab_all": "Toutes les commandes périodiques", + "account.recurring_orders.tab_my": "Mes commandes périodiques", + "account.recurring_orders.table.buyer": "Acheteur", + "account.recurring_orders.table.date_of_order": "Date de création", + "account.recurring_orders.table.id_of_order": "N°", + "account.recurring_orders.table.id_of_order.aria_label": "Naviguer vers les détails de la commande", + "account.recurring_orders.table.last_order_date": "Dernière commande", + "account.recurring_orders.table.next_order_date": "Commande suivante", + "account.recurring_orders.table.order_frequency": "Récurrent tous les", + "account.recurring_orders.table.order_total": "Total", "account.register.address.headding": "Adresse", "account.register.address.message": "Pour gagner du temps lors de la commande, veuillez indiquer votre adresse de facturation ou de livraison ci-dessous. Nous conserverons ces informations afin que vous n’ayez pas à les saisir à nouveau.", "account.register.company_information.heading": "Informations sur la société", @@ -623,9 +675,10 @@ "approval.details.conditions.budget_limit": "La demande d’achat dépasse votre budget.", "approval.details.conditions.cost_center": "La demande d’achat est attribuée à un centre de coûts.", "approval.details.conditions.order_spend_limit": "La demande d’achat dépasse votre limite de dépenses par commande.", + "approval.details.conditions.recurring_order": "La demande d’achat déclenche une commande périodique.", "approval.details.contacts.heading": "Contacts des approbateurs", "approval.details.cost_center_approvers.people_allowed": "La personne suivante est autorisée à approuver votre demande d’achat, car elle est associée au centre de coûts {{0}}:", - "approval.details.customer_approvers.people_allowed": "Les personnes suivantes sont autorisées à approuver votre demande d’achat, lorsque la limite de dépenses ou le budget sont dépassés :", + "approval.details.customer_approvers.people_allowed": "Les personnes suivantes sont autorisées à approuver votre demande d’achat si la limite de dépenses par commande et/ou les limites budgétaires sont dépassées ou s’il s’agit d’une commande périodique :", "approval.details.heading": "Détails de l’approbation", "approval.details.place_order": "Lorsque vous passez une demande d’achat nécessitant une approbation, un courriel est envoyé à toutes les personnes autorisées à approuver cette demande d’achat. Vous serez informé par courriel lorsque votre demande d’achat aura été approuvée ou rejetée.", "approval.detailspage.approval.heading": "Détails de l’approbation", @@ -636,7 +689,7 @@ "approval.detailspage.approval_status.system_rejected.status": "Rejeté par le système", "approval.detailspage.approve.button.label": "Approuver", "approval.detailspage.approver.label": "{{0, plural, one{Approbateur} other{Approbateurs}}}", - "approval.detailspage.budget.including_order.label": "Comprenant cette commande", + "approval.detailspage.budget.including_order.label": "{{recurring, select, =true{Comprenant la première commande} =false{Comprenant cette commande}}}", "approval.detailspage.buyer.approver.label": "{{0, plural, one{Approbateur} other{Approbateurs}}} de l’acheteur", "approval.detailspage.buyer.budget.label": "{{0, select, =weekly{Budget de l’acheteur (hebdomadaire)} =monthly{Budget de l’acheteur (mensuel)} =quarterly{Budget de l’acheteur (trimestriel)} =half-yearly{Budget de l’acheteur (semestriel)} =yearly{Budget de l’acheteur (annuel)}}}", "approval.detailspage.buyer.label": "Acheteur", @@ -651,7 +704,7 @@ "approval.detailspage.order.request_id": "ID de la demande d’achat", "approval.detailspage.order_date.label": "Date de création", "approval.detailspage.order_details.heading": "Détails de la Commande", - "approval.detailspage.order_reference_id.label": "ID de référence de la commande", + "approval.detailspage.order_reference_id.label": "{{recurring, select, =true{N° de la commande périodique} =false{N° de la commande}}}", "approval.detailspage.order_spend_limit.label": "Limite de dépenses de la commande", "approval.detailspage.order_total.label": "Total", "approval.detailspage.reject.button.label": "Rejeter", @@ -768,6 +821,7 @@ "checkout.hide_all.link": "Cacher des articles", "checkout.id_of_order.label": "Votre N° de commande:", "checkout.order.number.label": "Votre numéro de commande est :", + "checkout.order.recurring.number.label": "Votre numéro de commande périodique est le suivant :", "checkout.order.shipping.label": "Livraison", "checkout.order.total_cost.label": "Coût total", "checkout.orderReferenceId.apply.button.label": "Appliquer", @@ -778,6 +832,9 @@ "checkout.order_details.heading": "Récapitulatif de la commande", "checkout.order_review.heading.text": "Vérifiez les détails de votre commande ci-dessous et faites des modifications si nécessaire. Cliquez « Soumettre la commande » pour effectuer l’achat.", "checkout.order_review.heading.title": "Vérifier vos informations de commande", + "checkout.order_review.recurring.heading.text": "Vérifiez les détails de votre commande périodique ci-dessous et faites des modifications si nécessaire. Cliquez « Soumettre la commande périodique » pour effectuer l’achat.", + "checkout.order_review.recurring.heading.title": "Vérifier vos informations de commande périodique", + "checkout.order_review.recurring.send.button": "Soumettre la commande périodique", "checkout.order_review.send.button": "Soumettre la commande", "checkout.order_summary.heading": "Récapitulatif de la commande", "checkout.payment.addPayment.link": "Ajouter un moyen de paiement", @@ -820,6 +877,7 @@ "checkout.receipt.notification.mail.text": "Nous vous enverrons un courriel à {{0}} pour vous tenir au courant du statut de votre commande.", "checkout.receipt.order_pending.message": "Le statut de la commande reste en suspens jusqu’à ce que le paiement soit terminé.", "checkout.receipt.print.button.label": "Imprimer le reçu", + "checkout.receipt.recurring.tankyou.message": "Merci pour votre commande périodique", "checkout.receipt.tankyou.message": "Merci pour votre commande", "checkout.restriction.error": "Veuillez corriger les erreurs indiquées ci-dessous", "checkout.safeandsecure.details.title": "Achats sécurisés", @@ -855,6 +913,7 @@ "checkout.termsandconditions.details.title": "Conditions générales", "checkout.update.label": "Modifier {{0}}", "checkout.variation.edit.button.label": "Modifier", + "checkout.widget.additional-information.heading": "Informations supplémentaires", "checkout.widget.billing-address.heading": "Adresse de facturation", "checkout.widget.buyer.TaxationID": "Numéro d’identification fiscale:", "checkout.widget.buyer.costcenter": "Centre de coûts", @@ -960,6 +1019,23 @@ "navigation.paging.previous_page.label": "Aller à la page précédente", "number.decrease.text": "-", "number.increase.text": "+", + "order.recurrence.end.label": "Fin", + "order.recurrence.form.duration.label": "Récurrent tous les", + "order.recurrence.form.ending.date.label": "Jusqu’à", + "order.recurrence.form.ending.repetitions.label": "Se termine après", + "order.recurrence.form.info.text": "commandes", + "order.recurrence.form.period.label": "Récurrent tous les", + "order.recurrence.form.startDate.label": "Debute le", + "order.recurrence.heading": "Commande périodique", + "order.recurrence.interval.label": "Récurrent tous les", + "order.recurrence.period.days": "{{0, plural, one{# jour} other{# jours}}}", + "order.recurrence.period.months": "{{0, plural, one{# mois} other{# mois}}}", + "order.recurrence.period.weeks": "{{0, plural, one{# semaine} other{# semaines}}}", + "order.recurrence.period.years": "{{0, plural, one{# année} other{# années}}}", + "order.recurrence.recurring_order.label": "Commande périodique", + "order.recurrence.repetitions.label": "Fin", + "order.recurrence.single_order.label": "Achat unique", + "order.recurrence.start.label": "Debute le", "order.tracking.error": "Malheureusement, nous n’avons pas pu localiser une commande avec les informations que vous avez fournies.", "order_template.create.heading": "Créer un modèle de commande", "payment.error.PaymentInstrumentAlreadyExists": "Le moyen de paiement n’a pas pu être mis en place. Les données de paiement avec les paramètres fournis existent déjà.", diff --git a/src/styles/global/global.scss b/src/styles/global/global.scss index a9575a0be3..6eb62589f2 100644 --- a/src/styles/global/global.scss +++ b/src/styles/global/global.scss @@ -264,3 +264,7 @@ img.marketing { margin-bottom: ($space-default * 2); clear: both; } + +.hidden { + visibility: hidden; +} diff --git a/src/styles/pages/checkout/shopping-cart.scss b/src/styles/pages/checkout/shopping-cart.scss index 8dc9fc4a58..b5a77c56d3 100644 --- a/src/styles/pages/checkout/shopping-cart.scss +++ b/src/styles/pages/checkout/shopping-cart.scss @@ -96,11 +96,6 @@ margin-bottom: $space-default; border-bottom-width: 1px; } - - .form-control { - padding: 0; - background-color: inherit; - } } .product-out-of-stock { From ba50fa9d12db11fd0e1fab8e15908fa5f389f1c4 Mon Sep 17 00:00:00 2001 From: Mandy Glatter Date: Tue, 1 Oct 2024 12:03:11 +0200 Subject: [PATCH 6/8] docs: adjustments after review --- docs/guides/formly.md | 68 +++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/guides/formly.md b/docs/guides/formly.md index a114aa95ae..cf6ac28f18 100644 --- a/docs/guides/formly.md +++ b/docs/guides/formly.md @@ -39,7 +39,7 @@ In Formly, forms are defined as an array of field configurations and a correspon ### Formly-Form Component To render a form using Formly, the `` component is used. -Simply insert it into your template and pass the following inputs: +Insert it into your template and pass the following inputs: - **fields**: An array of type `FormlyFieldConfig[]` - **model**: An object containing key-value-pairs for each form field @@ -184,7 +184,7 @@ There are many options when it comes to [adding custom validation to formly form The PWA comes with some predefined custom validators which can be found in [special-validators.ts](../../src/app/shared/forms/validators/special-validators.ts). These can be added directly to the `validators.validation` property of a `FormlyFieldConfig`. -Don't forget to also add the corresponding error message to the `validation` property. +Make sure to also add the corresponding error message to the `validation` property. Alternatively, validation can be defined as a key-value pair directly in the `validation` property. However, adding validators here requires a different format: @@ -228,7 +228,7 @@ You can find a simple example of a custom type test in [text-input.field.compone ### Testing Wrappers -To test custom wrappers, create a `FormlyTestingContainerComponent` component, configure the `FormlyModule` with an example type (for example `FormlyTestingExampleComponent`) and the wrapper and set an appropriate testing configuration. +To test custom wrappers, create a `FormlyTestingContainerComponent` component, configure the `FormlyModule` with an example type (for example `FormlyTestingExampleComponent`) and the wrapper, and set an appropriate testing configuration. You can find a simple example of a wrapper test in [maxlength-description-wrapper.component.spec.ts](../../src/app/shared/formly/wrappers/maxlength-description-wrapper/maxlength-description-wrapper.component.spec.ts). @@ -236,7 +236,7 @@ You can find a simple example of a wrapper test in [maxlength-description-wrappe There are multiple ways to adapt Formly for projects or development on the main repository. -If you implement widely used functionality that can be used in multiple components and different pages, add your field types, wrappers or extensions to `src/app/shared/formly` and register them in the `formly.module.ts`. +If you implement widely used functionality that can be used in multiple components and different pages, add your field types, wrappers, or extensions to `src/app/shared/formly` and register them in the `formly.module.ts`. If you need specific fields or behavior that is not used everywhere, it would not be a good idea to pollute `formly.module.ts`. Instead, register your logic in the relevant module using `FormlyModule.forChild()`. @@ -254,43 +254,43 @@ Refer to the tables below for an overview of these parts. - Template option `inputClass`: These CSS class(es) will be added to all input/select/textarea/text tags. - Template option `ariaLabel`: Adds an aria-label to all input/select/textarea tags. -| Name | Description | Relevant props | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ish-text-input-field | Basic input field, supports all text types | `type`: 'text (default),'email','tel','password'. `mask`: input mask for a needed pattern (see [ngx-mask](https://www.npmjs.com/package/ngx-mask) for more information) | -| ish-select-field | Basic select field | `options`: `{ value: any; label: string}[]` or Observable. `placeholder`: Translation key or string for the default selection. `optionsTranslateDisabled`: Disables options label translation (placeholder is still translated). | -| ish-textarea-field | Basic textarea field | `cols` & `rows`: Specifies the dimensions of the textarea | -| ish-checkbox-field | Basic checkbox input | `title`: Title for a checkbox | -| ish-email-field | Email input field that automatically adds an e-mail validator and error messages | ---- | -| ish-password-field | Password input field that automatically adds a password validator and error messages | ---- | -| ish-phone-field | Phone number input field that automatically adds a phone number validator and error messages | ---- | -| ish-fieldset-field | Wraps fields in a `
    ` tag for styling | `fieldsetClass`: Class that will be added to the fieldset tag. `childClass`: Class that will be added to the child div. `legend`: Legend element that will be added to the fieldset, use the value as the legend text. `legendClass`: Class that will be added to the legend tag. | -| ish-captcha-field | Includes the `` component and adds the relevant `formControls` to the form | `topic`: Topic that will be passed to the Captcha component. | -| ish-radio-field | Basic radio input | ---- | -| ish-radio-group-field | Radio button group inline for price type selection | `opts`: Array of label/value pairs | -| ish-plain-text-field | Only display the form value | ---- | -| ish-html-text-field | Only display the form value as html | ---- | -| ish-date-picker-field | Basic datepicker | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `isSatExcluded`: Specifies if saturdays can be disabled. `isSunExcluded`: Specifies if sundays can be disabled. | -| ish-date-range-picker-field | Datepicker with range | `minDays`: Computes the minDate by adding the minimum allowed days to today. `maxDays`: Computes the maxDate by adding the maximum allowed days to today. `startDate`: The start date. `placeholder`: Placeholder that displays the date format in the input field. | -| ish-number-field | Basic number input field for smaller Integer numbers, with `+` and `-` buttons (use `ish-text-input-field` with `mask` for larger numbers) | `min`, `max` and `step` input configuration is considered by the in-/decrease buttons | -| ish-information-field | Include any freestyle text within a Formly generated form (HTML content is supported) | provide text via `localizationKey` or just plain `text` and adapt the styling via `containerClass` | +| Name | Description | Relevant properties | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ish-text-input-field | Basic input field, supports all text types | `type`: 'text' (default),'email','tel','password'. `mask`: input mask for a needed pattern (see [ngx-mask](https://www.npmjs.com/package/ngx-mask) for more information) | +| ish-select-field | Basic select field | `options`: `{ value: any; label: string}[]` or Observable. `placeholder`: Translation key or string for the default selection. `optionsTranslateDisabled`: Disables options label translation (placeholder is still translated). | +| ish-textarea-field | Basic textarea field | `cols` & `rows`: Specifies the dimensions of the textarea | +| ish-checkbox-field | Basic checkbox input | `title`: Title for a checkbox | +| ish-email-field | E-mail input field that automatically adds an e-mail validator and error messages | ---- | +| ish-password-field | Password input field that automatically adds a password validator and error messages | ---- | +| ish-phone-field | Phone number input field that automatically adds a phone number validator and error messages | ---- | +| ish-fieldset-field | Wraps fields in a `
    ` tag for styling | `fieldsetClass`: Class that will be added to the fieldset tag. `childClass`: Class that will be added to the child div. `legend`: Legend element that will be added to the fieldset. Use the value as the legend text. `legendClass`: Class that will be added to the legend tag. | +| ish-captcha-field | Includes the `` component and adds the relevant `formControls` to the form | `topic`: Topic that will be passed to the Captcha component | +| ish-radio-field | Basic radio input | ---- | +| ish-radio-group-field | Radio button group inline for price type selection | `opts`: Array of label/value pairs | +| ish-plain-text-field | Only display the form value | ---- | +| ish-html-text-field | Only display the form value as html | ---- | +| ish-date-picker-field | Basic datepicker | `minDays`: Computes the minDate by adding the minimum number of days allowed to the current date. `maxDays`: Computes the maxDate by adding the maximum number of days allowed to the current date. `isSatExcluded`: Specifies if Saturdays can be disabled. `isSunExcluded`: Specifies if Sundays can be disabled. | +| ish-date-range-picker-field | Datepicker with range | `minDays`: Computes the minDate by adding the minimum number of days allowed to the current date. `maxDays`: Computes the maxDate by adding the maximum number of days allowed to the current date. `startDate`: The start date. `placeholder`: Placeholder that displays the date format in the input field. | +| ish-number-field | Basic number input field for smaller Integer numbers, with `+` and `-` buttons (use `ish-text-input-field` with `mask` for larger numbers) | `min`, `max` and `step` input configuration is considered by the in-/decrease buttons | +| ish-information-field | Include any freestyle text within a Formly generated form (HTML content is supported) | Provide text via `localizationKey` or just plain `text` and adapt the styling via `containerClass` | ### Wrappers -| Name | Functionality | Relevant props | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| form-field-horizontal | Adds a label next to the field, adds a `required` marker and adds red styling for invalid fields. | `labelClass` & `fieldClass`: Classes that will be added to the label or field `
    `. `labelNoTranslate`: Prevents the label from being translated (e.g. if it is not a translation key). `hideRequiredMarker`: Hides the required marker while still validating a `required` field. | -| form-field-checkbox-horizontal | Adds a label for a checkbox or radio field, adds a `required` marker, adds red styling and error messages for invalid fields. Adds a title for a checkbox, if provided. Uses `validators.validation` and `validation.messages` properties. Adds a tooltip behind the label, see also tooltip-wrapper | `labelClass`, `titleClass` & `fieldClass`: Classes that will be added to the label, title or the outer field `
    `. `labelNoTranslate`, `titleNoTranslate`: Prevents the label or title from being translated. . `hideRequiredMarker`: Hides the required marker while still validating a `required` field. | -| validation | Adds validation icons and error messages to the field. Uses `validators.validation` and `validation.messages` properties. | `showValidation`: `(field: FormlyFieldConfig) => boolean`: optional, used to determine whether to show validation check marks | -| maxlength-description | Adds a description to textarea fields, including the amount of remaining characters (added to textarea by default, can be used for other fields as well). | `maxLength`: Specifies the maximum length to be displayed in the message (required). `maxLengthDescription`: Translation key for the maxlength description (default: 'textarea.max_limit' ). | -| description | Adds a custom description to any field | `customDescription`: `string` or `{key: string; args: any}` that will be translated | -| tooltip | Adds a tooltip to a field. Includes `` component. | `tooltip`: `{ title?: string; text: string; link: string }` that defines the different tooltip texts. | -| input-addon | Adds a prepended or appended text to a field, e.g. a currency or unit. | `addonLeft?`: `{ text: string \| Observable; }, addonRight?: {text: string \| Observable}` that defines the addon texts. | +| Name | Functionality | Relevant properties | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| form-field-horizontal | Adds a label next to the field, adds a `required` marker, and adds red styling for invalid fields. | `labelClass` & `fieldClass`: Classes that will be added to the label or field `
    `. `labelNoTranslate`: Prevents the label from being translated (e.g., if it is not a translation key). `hideRequiredMarker`: Hides the required marker while still validating a `required` field. | +| form-field-checkbox-horizontal | Adds a label for a checkbox or radio field, adds a `required` marker, adds red styling and error messages for invalid fields. Adds a title for a checkbox if provided. Uses `validators.validation` and `validation.messages` properties. Adds a tooltip behind the label, see also tooltip wrapper | `labelClass`, `titleClass` & `fieldClass`: Classes that will be added to the label, title, or the outer field `
    `. `labelNoTranslate`, `titleNoTranslate`: Prevents the label or title from being translated. `hideRequiredMarker`: Hides the required marker while still validating a `required` field. | +| validation | Adds validation icons and error messages to the field. Uses `validators.validation` and `validation.messages` properties. | `showValidation`: `(field: FormlyFieldConfig) => boolean`: optional, used to determine whether to show validation check marks | +| maxlength-description | Adds a description to textarea fields, including the amount of remaining characters (added to textarea by default, can be used for other fields as well). | `maxLength`: Specifies the maximum length to be displayed in the message (required). `maxLengthDescription`: Translation key for the maxlength description (default: 'textarea.max_limit' ). | +| description | Adds a custom description to any field | `customDescription`: `string` or `{key: string; args: any}` that will be translated | +| tooltip | Adds a tooltip to a field. Includes `` component. | `tooltip`: `{ title?: string; text: string; link: string }` that defines the different tooltip texts | +| input-addon | Adds a prepended or appended text to a field, e.g., a currency or unit. | `addonLeft?`: `{ text: string \| Observable; }, addonRight?: {text: string \| Observable}` that defines the addon texts | ### Extensions -| Name | Functionality | Relevant props | +| Name | Functionality | Relevant properties | | ------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | hide-if-empty | Hides fields of type `ish-select-field` that have an empty `options` attribute. | `options`: used to determine emptiness. | | translate-select-options | Automatically translates option labels and adds a placeholder option. | `options`: options whose labels will be translated. `placeholder`: used to determine whether to set placeholder and its text. `optionsTranslateDisabled`: disables options label translation (placeholder is still translated). | -| translate-placeholder | Automatically translates the placeholder value | `placeholder`: value to be translated. | +| translate-placeholder | Automatically translates the placeholder value. | `placeholder`: value to be translated. | | post-wrappers | Appends wrappers to the default ones defined in the `FormlyModule` | `postWrappers`: `[]` of extensions to append to the default wrappers, optional index to specify at which position the wrapper should be inserted. | From 8439a07b785d0d6fc514050f1e0ad34a56820553 Mon Sep 17 00:00:00 2001 From: Mandy Glatter Date: Wed, 22 Jan 2025 16:47:36 +0100 Subject: [PATCH 7/8] i18n: adjust grammar in French localization --- src/assets/i18n/fr_FR.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index e91cfa0209..b3b8350309 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -470,7 +470,7 @@ "account.recurring_orders.table.id_of_order.aria_label": "Naviguer vers les détails de la commande", "account.recurring_orders.table.last_order_date": "Dernière commande", "account.recurring_orders.table.next_order_date": "Commande suivante", - "account.recurring_orders.table.order_frequency": "Récurrent tous les", + "account.recurring_orders.table.order_frequency": "Récurrente tous les", "account.recurring_orders.table.order_total": "Total", "account.register.address.headding": "Adresse", "account.register.address.message": "Pour gagner du temps lors de la commande, veuillez indiquer votre adresse de facturation ou de livraison ci-dessous. Nous conserverons ces informations afin que vous n’ayez pas à les saisir à nouveau.", @@ -1020,18 +1020,18 @@ "number.decrease.text": "-", "number.increase.text": "+", "order.recurrence.end.label": "Fin", - "order.recurrence.form.duration.label": "Récurrent tous les", + "order.recurrence.form.duration.label": "Récurrente tous les", "order.recurrence.form.ending.date.label": "Jusqu’à", "order.recurrence.form.ending.repetitions.label": "Se termine après", "order.recurrence.form.info.text": "commandes", - "order.recurrence.form.period.label": "Récurrent tous les", + "order.recurrence.form.period.label": "Récurrente tous les", "order.recurrence.form.startDate.label": "Debute le", "order.recurrence.heading": "Commande périodique", - "order.recurrence.interval.label": "Récurrent tous les", + "order.recurrence.interval.label": "Récurrente tous les", "order.recurrence.period.days": "{{0, plural, one{# jour} other{# jours}}}", "order.recurrence.period.months": "{{0, plural, one{# mois} other{# mois}}}", "order.recurrence.period.weeks": "{{0, plural, one{# semaine} other{# semaines}}}", - "order.recurrence.period.years": "{{0, plural, one{# année} other{# années}}}", + "order.recurrence.period.years": "{{0, plural, one{# an} other{# ans}}}", "order.recurrence.recurring_order.label": "Commande périodique", "order.recurrence.repetitions.label": "Fin", "order.recurrence.single_order.label": "Achat unique", From 79e997a347b7160f9fc66f43120e041cb921890b Mon Sep 17 00:00:00 2001 From: Stefan Hauke Date: Wed, 22 Jan 2025 20:29:37 +0100 Subject: [PATCH 8/8] feat: disable recurring orders for punchout users + punchout user my account cleanup --- .../account-overview/account-overview.component.html | 7 +++++++ .../account-navigation.items.b2c.ts | 2 +- .../account-navigation/account-navigation.items.ts | 12 +++++++++++- .../shopping-basket/shopping-basket.component.html | 10 ++++++---- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/app/pages/account-overview/account-overview/account-overview.component.html b/src/app/pages/account-overview/account-overview/account-overview.component.html index 295cd6f52d..2999f55034 100644 --- a/src/app/pages/account-overview/account-overview/account-overview.component.html +++ b/src/app/pages/account-overview/account-overview/account-overview.component.html @@ -80,6 +80,13 @@ + + +
    + +
    +
    +

    {{ 'account.overview.note.heading' | translate }}: {{ 'checkout.order_details.heading' | translate }}

    - + + +