diff --git a/Dockerfile b/Dockerfile index 0e42a45242..020250819c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ COPY schematics /workspace/schematics/ COPY package.json package-lock.json /workspace/ RUN npm i --ignore-scripts COPY projects/organization-management/src/app /workspace/projects/organization-management/src/app/ +COPY projects/requisition-management/src/app /workspace/projects/requisition-management/src/app/ COPY src /workspace/src/ COPY tsconfig.app.json tsconfig.base.json ngsw-config.json .browserslistrc angular.json /workspace/ RUN npm run build:schematics && npm run synchronize-lazy-components -- --ci diff --git a/angular.json b/angular.json index 174f839e0d..ef13e5b79d 100644 --- a/angular.json +++ b/angular.json @@ -213,6 +213,52 @@ } } } + }, + "requisition-management": { + "projectType": "application", + "cli": { + "defaultCollection": "intershop-schematics" + }, + "schematics": { + "intershop-schematics:page": { + "lazy": false + } + }, + "root": "projects/requisition-management", + "sourceRoot": "projects/requisition-management/src", + "prefix": "ish", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/requisition-management", + "index": "projects/requisition-management/src/index.html", + "main": "projects/requisition-management/src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "projects/requisition-management/tsconfig.app.json", + "aot": false, + "styles": ["src/styles/themes/default/style.scss"], + "assets": [ + { "glob": "**/*", "input": "src/assets/", "output": "/assets/" } + ], + "scripts": [], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.local.ts" + } + ] + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "requisition-management:build", + "disableHostCheck": true, + "host": "0.0.0.0" + } + } + } } }, "defaultProject": "intershop-pwa", diff --git a/e2e/cypress/integration/pages/account/my-account.page.ts b/e2e/cypress/integration/pages/account/my-account.page.ts index e5e7be964a..1739b0e89f 100644 --- a/e2e/cypress/integration/pages/account/my-account.page.ts +++ b/e2e/cypress/integration/pages/account/my-account.page.ts @@ -10,25 +10,17 @@ export class MyAccountPage { } navigateToQuoting() { - cy.get('a[data-testing-id="quoute-list-link"]').click(); + cy.get('a[data-testing-id="quote-list-link"]').click(); } - get newQuoteLabel() { - return cy.get('[data-testing-id="new-counter"]'); + get respondedQuotesCount() { + return cy.get('[data-testing-id="responded-counter"]'); } - get submittedQuoteLabel() { + get submittedQuotesCount() { return cy.get('[data-testing-id="submitted-counter"]'); } - get acceptedQuoteLabel() { - return cy.get('[data-testing-id="accepted-counter"]'); - } - - get rejectedQuoteLabel() { - return cy.get('[data-testing-id="rejected-counter"]'); - } - navigateToAddresses() { cy.get('a[data-testing-id="addresses-link"]').click(); } diff --git a/e2e/cypress/integration/pages/checkout/checkout-payment.page.ts b/e2e/cypress/integration/pages/checkout/checkout-payment.page.ts index 186e570747..50efb68950 100644 --- a/e2e/cypress/integration/pages/checkout/checkout-payment.page.ts +++ b/e2e/cypress/integration/pages/checkout/checkout-payment.page.ts @@ -52,7 +52,11 @@ export class CheckoutPaymentPage { }, formError(key: string) { - return cy.get(`[data-testing-id=payment-parameter-form-${method}]`).find(`[data-testing-id='${key}']`).next(); + return cy + .get(`[data-testing-id=payment-parameter-form-${method}]`) + .find(`[data-testing-id='${key}']`) + .parent() + .next(); }, }; } diff --git a/e2e/cypress/integration/pages/organizationmanagement/user-edit-budget.page.ts b/e2e/cypress/integration/pages/organizationmanagement/user-edit-budget.page.ts new file mode 100644 index 0000000000..5bc9c7023e --- /dev/null +++ b/e2e/cypress/integration/pages/organizationmanagement/user-edit-budget.page.ts @@ -0,0 +1,27 @@ +import { fillFormField } from '../../framework'; + +declare interface B2BUserBudgetForm { + budget: number; + orderSpentLimit: number; + budgetPeriod: string; +} + +export class UserEditBudgetPage { + readonly tag = 'ish-user-edit-budget-page'; + + private submitButton = () => cy.get('[data-testing-id="edit-budget-submit"]'); + + fillForm(content: B2BUserBudgetForm) { + Object.keys(content) + .filter(key => content[key] !== undefined) + .forEach((key: keyof B2BUserBudgetForm) => { + fillFormField(this.tag, key, content[key]); + }); + + return this; + } + + submit() { + this.submitButton().click(); + } +} diff --git a/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts b/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts index 7d3898a9d7..743969e351 100644 --- a/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts +++ b/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts @@ -21,6 +21,10 @@ export class UsersDetailPage { return cy.get('[data-testing-id="edit-roles"]').click(); } + editBudget() { + return cy.get('[data-testing-id="edit-budget"]').click(); + } + goToUserManagement() { cy.get('[data-testing-id="back-to-user-management"]').click(); } @@ -28,4 +32,16 @@ export class UsersDetailPage { get rolesAndPermissions() { return cy.get(this.tag).find('[data-testing-id="user-roles-fields"]'); } + + get orderSpendLimit() { + return cy.get(this.tag).find('[data-testing-id="order-spend-limit-field"]'); + } + + get budget() { + return cy.get(this.tag).find('[data-testing-id="budget-field"]'); + } + + get userBudget() { + return cy.get(this.tag).find('[data-testing-id="user-budget"]'); + } } diff --git a/e2e/cypress/integration/pages/organizationmanagement/users.page.ts b/e2e/cypress/integration/pages/organizationmanagement/users.page.ts index 6ba6ef7b5b..fe6eb0f56b 100644 --- a/e2e/cypress/integration/pages/organizationmanagement/users.page.ts +++ b/e2e/cypress/integration/pages/organizationmanagement/users.page.ts @@ -35,4 +35,14 @@ export class UsersPage { rolesOfUser(id: string) { return cy.get(`[data-testing-id="user-roles-${id}"]`); } + + hoverUserBudgetProgressBar(id: string) { + cy.get(`[data-testing-id="user-budget-${id}"]`) + .find('[data-testing-id="user-budget-popover"]') + .trigger('mouseover'); + } + + getUserBudgetPopover(id: string) { + return cy.get(`[data-testing-id="user-budget-${id}"]`).find('[data-testing-id="user-budget-popover"]'); + } } diff --git a/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts b/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts index c3f0e3cfb7..486c2ab630 100644 --- a/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts +++ b/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts @@ -16,7 +16,9 @@ const _ = { name: 'Patricia Miller', email: 'pmiller@test.intershop.de', role: 'Buyer', - permission: 'View cost objects', + permission: 'Manage purchases', + orderSpendLimit: '$500.00', + budget: '$10,000.00', }, }; @@ -34,6 +36,13 @@ describe('User Management', () => { }); }); + it('should show budget popover on progress bar mouseover', () => { + at(UsersPage, page => { + page.hoverUserBudgetProgressBar(_.selectedUser.email); + page.getUserBudgetPopover(_.selectedUser.email).should('exist'); + }); + }); + it('should be able to see user details', () => { at(UsersPage, page => { page.goToUserDetailLink(_.selectedUser.email); @@ -44,6 +53,8 @@ describe('User Management', () => { page.rolesAndPermissions.should('not.contain', _.user.role); page.rolesAndPermissions.should('contain', _.selectedUser.role); page.rolesAndPermissions.should('contain', _.selectedUser.permission); + page.orderSpendLimit.should('contain', `${_.selectedUser.orderSpendLimit}`); + page.budget.should('contain', `${_.selectedUser.budget}`); }); }); }); diff --git a/e2e/cypress/integration/specs/organizationmanagement/user-management-crud.b2b.e2e-spec.ts b/e2e/cypress/integration/specs/organizationmanagement/user-management-crud.b2b.e2e-spec.ts index b449dbce98..17d0743552 100644 --- a/e2e/cypress/integration/specs/organizationmanagement/user-management-crud.b2b.e2e-spec.ts +++ b/e2e/cypress/integration/specs/organizationmanagement/user-management-crud.b2b.e2e-spec.ts @@ -3,6 +3,7 @@ import { createB2BUserViaREST } from '../../framework/b2b-user'; import { LoginPage } from '../../pages/account/login.page'; import { sensibleDefaults } from '../../pages/account/registration.page'; import { UserCreatePage } from '../../pages/organizationmanagement/user-create.page'; +import { UserEditBudgetPage } from '../../pages/organizationmanagement/user-edit-budget.page'; import { UserEditRolesPage } from '../../pages/organizationmanagement/user-edit-roles.page'; import { UserEditPage } from '../../pages/organizationmanagement/user-edit.page'; import { UsersDetailPage } from '../../pages/organizationmanagement/users-detail.page'; @@ -21,11 +22,20 @@ const _ = { lastName: 'Doe', phone: '5551234', email: `j.joe${new Date().getTime()}@testcity.de`, + + orderSpentLimit: 100, + budget: 800, + budgetPeriod: 'monthly', }, roles: { autoRole: 'Buyer', assignedRole: 'Account Admin', }, + budget: { + orderSpentLimit: 200, + budget: 900, + budgetPeriod: 'weekly', + }, editUser: { title: 'Ms.', firstName: 'Jane', @@ -57,6 +67,7 @@ describe('User Management - CRUD', () => { at(UsersDetailPage, page => { page.name.should('contain', `${_.newUser.firstName} ${_.newUser.lastName}`); page.rolesAndPermissions.should('contain', _.roles.autoRole); + page.budget.should('contain', `${_.newUser.budget}`); }); }); @@ -84,6 +95,19 @@ describe('User Management - CRUD', () => { }); }); + it('should be able to edit budget', () => { + at(UsersDetailPage, page => page.editBudget()); + at(UserEditBudgetPage, page => { + page.fillForm(_.budget); + page.submit(); + }); + at(UsersDetailPage, page => { + page.budget.should('contain', `${_.budget.budget}`); + page.orderSpendLimit.should('contain', `${_.budget.orderSpentLimit}`); + page.userBudget.should('contain', `${_.budget.budgetPeriod}`); + }); + }); + it('should be able to delete user', () => { at(UsersDetailPage, page => page.goToUserManagement()); at(UsersPage, page => { diff --git a/e2e/cypress/integration/specs/quoting/quote-handling.b2b.e2e-spec.ts b/e2e/cypress/integration/specs/quoting/quote-handling.b2b.e2e-spec.ts index 070ed01323..95c8674a72 100644 --- a/e2e/cypress/integration/specs/quoting/quote-handling.b2b.e2e-spec.ts +++ b/e2e/cypress/integration/specs/quoting/quote-handling.b2b.e2e-spec.ts @@ -36,10 +36,8 @@ describe('Quote Handling', () => { it('should check number of quotes', () => { at(MyAccountPage, page => { - page.newQuoteLabel.should('have.text', '0'); - page.submittedQuoteLabel.should('have.text', '0'); - page.acceptedQuoteLabel.should('have.text', '0'); - page.rejectedQuoteLabel.should('have.text', '0'); + page.submittedQuotesCount.should('have.text', ' 0 '); + page.respondedQuotesCount.should('have.text', ' 0 '); }); }); @@ -127,10 +125,7 @@ describe('Quote Handling', () => { it('should check number of quotes again', () => { at(FamilyPage, page => page.header.goToMyAccount()); at(MyAccountPage, page => { - page.newQuoteLabel.should('have.text', '2'); - page.submittedQuoteLabel.should('have.text', '1'); - page.acceptedQuoteLabel.should('have.text', '0'); - page.rejectedQuoteLabel.should('have.text', '0'); + page.submittedQuotesCount.should('have.text', ' 1 '); }); }); }); diff --git a/jest.config.js b/jest.config.js index 9e33d20f99..6e4072c469 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ module.exports = { moduleNameMapper: { '^ish-(.*)$': '/src/app/$1', '^organization-management$': '/projects/organization-management/src/app/exports', + '^requisition-management$': '/projects/requisition-management/src/app/exports', }, snapshotSerializers: [ './src/jest-serializer/AngularHTMLSerializer.js', diff --git a/package-lock.json b/package-lock.json index ab0f769ea1..18ece73921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -696,6 +696,15 @@ "tslib": "^2.0.0" } }, + "@angular/cdk": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-11.0.2.tgz", + "integrity": "sha512-hdZ9UJVGgCFhdOuB4RPS1Ku45VSG/WfRjbyxu/7teYyFKqAvcd3vawkeQfZf+lExmFaeW43+5hnpu/JIlGTrSA==", + "requires": { + "parse5": "^5.0.0", + "tslib": "^2.0.0" + } + }, "@angular/cli": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-11.0.3.tgz", @@ -22880,8 +22889,7 @@ "parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" }, "parse5-html-rewriting-stream": { "version": "6.0.1", diff --git a/package.json b/package.json index ca0b6f475a..1986cb100a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@angular/animations": "~11.0.3", + "@angular/cdk": "^11.0.2", "@angular/common": "~11.0.3", "@angular/compiler": "~11.0.3", "@angular/core": "~11.0.3", diff --git a/projects/organization-management/src/app/components/budget-widget/budget-widget.component.html b/projects/organization-management/src/app/components/budget-widget/budget-widget.component.html new file mode 100644 index 0000000000..e2f49be57e --- /dev/null +++ b/projects/organization-management/src/app/components/budget-widget/budget-widget.component.html @@ -0,0 +1,23 @@ + + + + diff --git a/projects/organization-management/src/app/components/budget-widget/budget-widget.component.spec.ts b/projects/organization-management/src/app/components/budget-widget/budget-widget.component.spec.ts new file mode 100644 index 0000000000..7077d401de --- /dev/null +++ b/projects/organization-management/src/app/components/budget-widget/budget-widget.component.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AuthorizationToggleDirective } from 'ish-core/directives/authorization-toggle.directive'; +import { Price } from 'ish-core/models/price/price.model'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { UserBudgetComponent } from '../user-budget/user-budget.component'; + +import { BudgetWidgetComponent } from './budget-widget.component'; + +const budget = { + orderSpentLimit: { + currency: 'USD', + value: 500, + } as Price, + remainingBudget: { + currency: 'USD', + value: 8110.13, + } as Price, + spentBudget: { + currency: 'USD', + value: 1889.87, + } as Price, + budget: { + currency: 'USD', + value: 10000, + } as Price, + budgetPeriod: 'monthly', +}; + +describe('Budget Widget Component', () => { + let component: BudgetWidgetComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + let organizationManagementFacade: OrganizationManagementFacade; + + beforeEach(async () => { + organizationManagementFacade = mock(OrganizationManagementFacade); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ + BudgetWidgetComponent, + MockComponent(ErrorMessageComponent), + MockComponent(InfoBoxComponent), + MockComponent(LoadingComponent), + MockComponent(UserBudgetComponent), + MockDirective(AuthorizationToggleDirective), + ], + providers: [{ provide: OrganizationManagementFacade, useFactory: () => instance(organizationManagementFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BudgetWidgetComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(organizationManagementFacade.loggedInUserBudget$()).thenReturn(of(budget)); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should render loading component if budget is loading', () => { + when(organizationManagementFacade.loggedInUserBudgetLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); +}); diff --git a/projects/organization-management/src/app/components/budget-widget/budget-widget.component.ts b/projects/organization-management/src/app/components/budget-widget/budget-widget.component.ts new file mode 100644 index 0000000000..ca62edda49 --- /dev/null +++ b/projects/organization-management/src/app/components/budget-widget/budget-widget.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { UserBudget } from '../../models/user-budget/user-budget.model'; + +@Component({ + selector: 'ish-budget-widget', + templateUrl: './budget-widget.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BudgetWidgetComponent implements OnInit { + userBudget$: Observable; + budgetLoading$: Observable; + error$: Observable; + + constructor(private organizationManagementFacade: OrganizationManagementFacade) {} + + ngOnInit() { + this.userBudget$ = this.organizationManagementFacade.loggedInUserBudget$(); + this.budgetLoading$ = this.organizationManagementFacade.loggedInUserBudgetLoading$; + this.error$ = this.organizationManagementFacade.loggedInUserBudgetError$; + } +} diff --git a/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.html b/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.html new file mode 100644 index 0000000000..cdb35af426 --- /dev/null +++ b/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.html @@ -0,0 +1,53 @@ +
+
+ + + + +
+ + +
+
+ +
+
+
+ {{ currencySymbol }} +
+ + +
+ +
+ + +
+ +
+
+
+
+
+
diff --git a/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.spec.ts b/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.spec.ts new file mode 100644 index 0000000000..d5fb133d7f --- /dev/null +++ b/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { Locale } from 'ish-core/models/locale/locale.model'; +import { FormControlFeedbackComponent } from 'ish-shared/forms/components/form-control-feedback/form-control-feedback.component'; +import { InputComponent } from 'ish-shared/forms/components/input/input.component'; +import { ShowFormFeedbackDirective } from 'ish-shared/forms/directives/show-form-feedback.directive'; + +import { UserBudgetFormComponent } from './user-budget-form.component'; + +describe('User Budget Form Component', () => { + let component: UserBudgetFormComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let fb: FormBuilder; + let appFacade: AppFacade; + + beforeEach(async () => { + appFacade = mock(AppFacade); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, TranslateModule.forRoot()], + declarations: [ + MockComponent(FormControlFeedbackComponent), + MockComponent(InputComponent), + MockDirective(ShowFormFeedbackDirective), + UserBudgetFormComponent, + ], + providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserBudgetFormComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + fb = TestBed.inject(FormBuilder); + when(appFacade.currentLocale$).thenReturn(of({ currency: 'USD' } as Locale)); + + component.form = fb.group({ + budget: [''], + budgetPeriod: [''], + currency: [''], + }); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should write current currency into the form after init', () => { + fixture.detectChanges(); + + expect(component.form.get('currency').value).toBe('USD'); + }); + + it('should display form input fields after creation', () => { + fixture.detectChanges(); + + expect(element.querySelector('[controlname=orderSpentLimit]')).toBeTruthy(); + expect(element.querySelector('[formControlname=budget]')).toBeTruthy(); + expect(element.querySelector('[formControlname=budgetPeriod]')).toBeTruthy(); + }); +}); diff --git a/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.ts b/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.ts new file mode 100644 index 0000000000..64e6baa476 --- /dev/null +++ b/projects/organization-management/src/app/components/user-budget-form/user-budget-form.component.ts @@ -0,0 +1,61 @@ +import { getCurrencySymbol } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { AbstractControl, FormGroup } from '@angular/forms'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { Locale } from 'ish-core/models/locale/locale.model'; +import { whenTruthy } from 'ish-core/utils/operators'; + +@Component({ + selector: 'ish-user-budget-form', + templateUrl: './user-budget-form.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +export class UserBudgetFormComponent implements OnInit, OnDestroy { + @Input() form: FormGroup; + + currentLocale$: Observable; + periods = ['weekly', 'monthly', 'quarterly']; + currencySymbol = '$'; // fallback currency + + private destroy$ = new Subject(); + + constructor(private appFacade: AppFacade) {} + + ngOnInit() { + this.init(); + } + + init() { + if (!this.form) { + throw new Error('required input parameter
is missing for UserBudgetFormComponent'); + } + + if (!this.formControlBudget) { + throw new Error(`control 'budget' does not exist in the given form for UserBudgetFormComponent`); + } + + this.currentLocale$ = this.appFacade.currentLocale$; + + // if there is no predefined currency determine currency from default locale + + this.currentLocale$.pipe(whenTruthy(), takeUntil(this.destroy$)).subscribe(locale => { + if (locale.currency && !this.form.get('currency').value) { + this.form.get('currency').setValue(locale.currency); + this.form.updateValueAndValidity(); + } + this.currencySymbol = getCurrencySymbol(this.form.get('currency').value, 'wide', locale.lang); + }); + } + + get formControlBudget(): AbstractControl { + return this.form && this.form.get('budget'); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/organization-management/src/app/components/user-budget/user-budget.component.html b/projects/organization-management/src/app/components/user-budget/user-budget.component.html new file mode 100644 index 0000000000..d8455b06fd --- /dev/null +++ b/projects/organization-management/src/app/components/user-budget/user-budget.component.html @@ -0,0 +1,63 @@ +
+
+
{{ 'account.user.new.order_spend_limit.label' | translate }}
+
+ {{ budget.orderSpentLimit | ishPrice }} +
+ +
{{ 'account.budget.unlimited' | translate }}
+
+
+ + +
+
{{ 'account.budget.type.' + budget.budgetPeriod + '.label' | translate }}
+
{{ budget.budget | ishPrice }}
+
+
+
{{ 'account.budget.already_spent.label' | translate }}
+
{{ usedBudget | ishPrice }}
+
+
+
+
+ {{ usedBudgetPercentage | percent }} + +
+
+ +
+
{{ budget.budget | ishPrice }}
+
+
+
+ +
+
{{ usedBudget | ishPrice }} ({{ usedBudgetPercentage | percent }})
+
+
+
+ +
+
+ {{ budget.remainingBudget | ishPrice }} ({{ remainingBudgetPercentage | percent }}) +
+
+
+
+
+
+
+
+ + +
+
{{ 'account.user.budget.label' | translate }}
+
{{ 'account.budget.unlimited' | translate }}
+
+
diff --git a/projects/organization-management/src/app/components/user-budget/user-budget.component.spec.ts b/projects/organization-management/src/app/components/user-budget/user-budget.component.spec.ts new file mode 100644 index 0000000000..ef53707eb6 --- /dev/null +++ b/projects/organization-management/src/app/components/user-budget/user-budget.component.spec.ts @@ -0,0 +1,106 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; + +import { Price } from 'ish-core/models/price/price.model'; +import { PricePipe } from 'ish-core/models/price/price.pipe'; + +import { UserBudgetComponent } from './user-budget.component'; + +describe('User Budget Component', () => { + let component: UserBudgetComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgbPopoverModule, TranslateModule.forRoot()], + declarations: [MockPipe(PricePipe, (price: Price) => `${price.currency} ${price.value}`), UserBudgetComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserBudgetComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + component.budget = { + orderSpentLimit: { + value: 100, + currency: 'USD', + type: 'Money', + }, + budget: { + value: 5000, + currency: 'USD', + type: 'Money', + }, + budgetPeriod: 'monthly', + remainingBudget: { + value: 2500, + currency: 'USD', + type: 'Money', + }, + spentBudget: { + value: 2500, + currency: 'USD', + type: 'Money', + }, + }; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => component.ngOnChanges()).not.toThrow(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display budget when rendering', () => { + component.ngOnChanges(); + fixture.detectChanges(); + + expect(element).toMatchInlineSnapshot(` +
+
+
account.user.new.order_spend_limit.label
+
USD 100
+
+
+
account.budget.type.monthly.label
+
USD 5000
+
+
+
account.budget.already_spent.label
+
USD 2500
+
+
+
+
+ 50% +
+
+
+
+ `); + }); + + it('should return 2500 used budget when remaining budget is 2500', () => { + component.ngOnChanges(); + + expect(component.usedBudget.value).toEqual(2500); + }); + + it('should display 50% for the used budget percentage', () => { + component.ngOnChanges(); + + expect(component.usedBudgetPercentage).toEqual(0.5); + }); + + it('should display 50% for the remaining budget', () => { + component.ngOnChanges(); + + expect(component.remainingBudgetPercentage).toEqual(0.5); + }); +}); diff --git a/projects/organization-management/src/app/components/user-budget/user-budget.component.ts b/projects/organization-management/src/app/components/user-budget/user-budget.component.ts new file mode 100644 index 0000000000..1cac3def3f --- /dev/null +++ b/projects/organization-management/src/app/components/user-budget/user-budget.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +import { Price } from 'ish-core/models/price/price.model'; + +import { UserBudget } from '../../models/user-budget/user-budget.model'; + +/** + * displays the user budget and the appropriate budget bar + */ +@Component({ + selector: 'ish-user-budget', + templateUrl: './user-budget.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +export class UserBudgetComponent implements OnChanges { + @Input() budget: UserBudget; + @Input() progressBarClass; + + usedBudget: Price; + usedBudgetPercentage: number; + remainingBudgetPercentage: number; + + ngOnChanges(): void { + if (this.isBudgetDefined()) { + this.calculate(); + } + } + + isBudgetDefined(): boolean { + return !!this.budget?.budget && !!this.budget?.remainingBudget; + } + + private calculate() { + this.usedBudget = this.budget.spentBudget; + this.usedBudgetPercentage = this.budget.budget.value === 0 ? 0 : this.usedBudget?.value / this.budget.budget.value; + this.remainingBudgetPercentage = + this.budget.budget.value === 0 ? 0 : this.budget.remainingBudget.value / this.budget.budget.value; + } +} diff --git a/projects/organization-management/src/app/exports/index.ts b/projects/organization-management/src/app/exports/index.ts index 9292a3fa57..1feb9542ab 100644 --- a/projects/organization-management/src/app/exports/index.ts +++ b/projects/organization-management/src/app/exports/index.ts @@ -1,3 +1,6 @@ export { OrganizationManagementModule } from '../organization-management.module'; - +export { OrganizationManagementExportsModule } from './organization-management-exports.module'; export { OrganizationManagementBreadcrumbService } from '../services/organization-management-breadcrumb/organization-management-breadcrumb.service'; + +export { UserBudget } from '../models/user-budget/user-budget.model'; +export { LazyBudgetWidgetComponent } from './lazy-budget-widget/lazy-budget-widget.component'; diff --git a/projects/organization-management/src/app/exports/lazy-budget-widget/lazy-budget-widget.component.html b/projects/organization-management/src/app/exports/lazy-budget-widget/lazy-budget-widget.component.html new file mode 100644 index 0000000000..59bc2dec80 --- /dev/null +++ b/projects/organization-management/src/app/exports/lazy-budget-widget/lazy-budget-widget.component.html @@ -0,0 +1,8 @@ + + diff --git a/projects/organization-management/src/app/exports/lazy-budget-widget/lazy-budget-widget.component.ts b/projects/organization-management/src/app/exports/lazy-budget-widget/lazy-budget-widget.component.ts new file mode 100644 index 0000000000..112ff48a44 --- /dev/null +++ b/projects/organization-management/src/app/exports/lazy-budget-widget/lazy-budget-widget.component.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Compiler, + Component, + Injector, + NgModuleFactory, + OnInit, + ViewChild, + ViewContainerRef, +} from '@angular/core'; + +@Component({ + selector: 'ish-lazy-budget-widget', + templateUrl: './lazy-budget-widget.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// tslint:disable-next-line:component-creation-test +export class LazyBudgetWidgetComponent implements OnInit { + /* + * WARNING! + * + * This file was automatically generated! + * It should be updated using: + * + * ng g lazy-component components/budget-widget/budget-widget.component.ts + * + */ + + @ViewChild('anchor', { read: ViewContainerRef, static: true }) anchor: ViewContainerRef; + + constructor(private compiler: Compiler, private injector: Injector) {} + + async ngOnInit() { + // prevent cyclic dependency warnings + const extension = 'organization-management'; + const moduleObj = await import(`../../${extension}.module`); + const module = moduleObj[Object.keys(moduleObj)[0]]; + + const { BudgetWidgetComponent } = await import('../../components/budget-widget/budget-widget.component'); + + const moduleFactory = await this.loadModuleFactory(module); + const moduleRef = moduleFactory.create(this.injector); + const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(BudgetWidgetComponent); + + this.anchor.createComponent(factory).changeDetectorRef.markForCheck(); + } + + private async loadModuleFactory(t) { + if (t instanceof NgModuleFactory) { + return t; + } else { + return await this.compiler.compileModuleAsync(t); + } + } +} diff --git a/projects/organization-management/src/app/exports/organization-management-exports.module.ts b/projects/organization-management/src/app/exports/organization-management-exports.module.ts new file mode 100644 index 0000000000..dfb6f39bf8 --- /dev/null +++ b/projects/organization-management/src/app/exports/organization-management-exports.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; + +import { LazyBudgetWidgetComponent } from './lazy-budget-widget/lazy-budget-widget.component'; + +@NgModule({ + imports: [], + declarations: [LazyBudgetWidgetComponent], + exports: [LazyBudgetWidgetComponent], +}) +export class OrganizationManagementExportsModule {} diff --git a/projects/organization-management/src/app/facades/organization-management.facade.ts b/projects/organization-management/src/app/facades/organization-management.facade.ts index 4d6323de7e..b34a83679a 100644 --- a/projects/organization-management/src/app/facades/organization-management.facade.ts +++ b/projects/organization-management/src/app/facades/organization-management.facade.ts @@ -7,6 +7,13 @@ import { toObservable } from 'ish-core/utils/functions'; import { mapToProperty, whenTruthy } from 'ish-core/utils/operators'; import { B2bUser } from '../models/b2b-user/b2b-user.model'; +import { UserBudget } from '../models/user-budget/user-budget.model'; +import { + getCurrentUserBudget, + getCurrentUserBudgetError, + getCurrentUserBudgetLoading, + loadBudget, +} from '../store/budget'; import { addUser, deleteUser, @@ -17,6 +24,7 @@ import { getUsers, getUsersError, getUsersLoading, + setUserBudget, setUserRoles, updateUser, } from '../store/users'; @@ -31,6 +39,14 @@ export class OrganizationManagementFacade { selectedUser$ = this.store.pipe(select(getSelectedUser)); users$ = this.store.pipe(select(getUsers)); + loggedInUserBudget$() { + this.store.dispatch(loadBudget()); + return this.store.pipe(select(getCurrentUserBudget)); + } + + loggedInUserBudgetLoading$ = this.store.pipe(select(getCurrentUserBudgetLoading)); + loggedInUserBudgetError$ = this.store.pipe(select(getCurrentUserBudgetError)); + addUser(user: B2bUser) { this.store.dispatch( addUser({ @@ -66,4 +82,10 @@ export class OrganizationManagementFacade { .pipe(take(1), whenTruthy(), mapToProperty('login')) .subscribe(login => this.store.dispatch(setUserRoles({ login, roles: roleIDs }))); } + + setSelectedUserBudget(budget: UserBudget) { + this.selectedUser$ + .pipe(take(1), whenTruthy(), mapToProperty('login')) + .subscribe(login => this.store.dispatch(setUserBudget({ login, budget }))); + } } diff --git a/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.spec.ts b/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.spec.ts index 9e1b96ff8f..9c2ebfaf39 100644 --- a/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.spec.ts +++ b/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.spec.ts @@ -55,6 +55,11 @@ describe('B2b User Mapper', () => { { name: 'firstName', value: 'Patricia' }, { name: 'lastName', value: 'Miller' }, { name: 'active', value: true }, + { name: 'budgetPeriod', type: 'String', value: 'monthly' }, + { name: 'orderSpentLimit', type: 'MoneyRO', value: { currency: 'USD', value: 500 } }, + { name: 'budget', type: 'MoneyRO', value: { currency: 'USD', value: 10000 } }, + { name: 'remainingBudget', type: 'MoneyRO', value: { currency: 'USD', value: 8000 } }, + { name: 'spentBudget', type: 'MoneyRO', value: { currency: 'USD', value: 2000 } }, ], } as B2bUserDataLink, ]; @@ -71,6 +76,25 @@ describe('B2b User Mapper', () => { "APP_B2B_COSTCENTER_OWNER", "APP_B2B_BUYER", ], + "userBudget": Object { + "budget": Object { + "currency": "USD", + "value": 10000, + }, + "budgetPeriod": "monthly", + "orderSpentLimit": Object { + "currency": "USD", + "value": 500, + }, + "remainingBudget": Object { + "currency": "USD", + "value": 8000, + }, + "spentBudget": Object { + "currency": "USD", + "value": 2000, + }, + }, }, ] `); diff --git a/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.ts b/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.ts index 19530db6a5..b116919544 100644 --- a/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.ts +++ b/projects/organization-management/src/app/models/b2b-user/b2b-user.mapper.ts @@ -17,6 +17,16 @@ export class B2bUserMapper { lastName: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'lastName'), roleIDs: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'roleIDs'), active: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'active'), + userBudget: { + orderSpentLimit: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'orderSpentLimit'), + budget: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'budget'), + remainingBudget: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'remainingBudget'), + budgetPeriod: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'budgetPeriod'), + spentBudget: AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'spentBudget') || { + ...AttributeHelper.getAttributeValueByAttributeName(e.attributes, 'budget'), + value: 0, + }, + }, })); } else { throw new Error('data is required'); diff --git a/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts b/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts index e45da7ec27..e994c2f496 100644 --- a/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts +++ b/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts @@ -1,6 +1,9 @@ import { User } from 'ish-core/models/user/user.model'; +import { UserBudget } from '../user-budget/user-budget.model'; + export interface B2bUser extends Partial { roleIDs?: string[]; active?: boolean; + userBudget?: UserBudget; } diff --git a/projects/organization-management/src/app/models/user-budget/user-budget.model.ts b/projects/organization-management/src/app/models/user-budget/user-budget.model.ts new file mode 100644 index 0000000000..774ec5e9d3 --- /dev/null +++ b/projects/organization-management/src/app/models/user-budget/user-budget.model.ts @@ -0,0 +1,9 @@ +import { Price } from 'ish-core/models/price/price.model'; + +export interface UserBudget { + budget: Price; + budgetPeriod: string; + orderSpentLimit: Price; + remainingBudget?: Price; + spentBudget?: Price; +} diff --git a/projects/organization-management/src/app/organization-management.module.ts b/projects/organization-management/src/app/organization-management.module.ts index 3ddedec990..3f4024a9db 100644 --- a/projects/organization-management/src/app/organization-management.module.ts +++ b/projects/organization-management/src/app/organization-management.module.ts @@ -2,11 +2,16 @@ import { NgModule } from '@angular/core'; import { SharedModule } from 'ish-shared/shared.module'; +import { BudgetWidgetComponent } from './components/budget-widget/budget-widget.component'; +import { UserBudgetFormComponent } from './components/user-budget-form/user-budget-form.component'; +import { UserBudgetComponent } from './components/user-budget/user-budget.component'; import { UserProfileFormComponent } from './components/user-profile-form/user-profile-form.component'; import { UserRolesSelectionComponent } from './components/user-roles-selection/user-roles-selection.component'; import { OrganizationManagementRoutingModule } from './pages/organization-management-routing.module'; import { UserCreatePageComponent } from './pages/user-create/user-create-page.component'; +import { UserDetailBudgetComponent } from './pages/user-detail/user-detail-budget/user-detail-budget.component'; import { UserDetailPageComponent } from './pages/user-detail/user-detail-page.component'; +import { UserEditBudgetPageComponent } from './pages/user-edit-budget/user-edit-budget-page.component'; import { UserEditProfilePageComponent } from './pages/user-edit-profile/user-edit-profile-page.component'; import { UserEditRolesPageComponent } from './pages/user-edit-roles/user-edit-roles-page.component'; import { UserRolesBadgesComponent } from './pages/users/user-roles-badges/user-roles-badges.component'; @@ -15,8 +20,13 @@ import { OrganizationManagementStoreModule } from './store/organization-manageme @NgModule({ declarations: [ + BudgetWidgetComponent, + UserBudgetComponent, + UserBudgetFormComponent, UserCreatePageComponent, + UserDetailBudgetComponent, UserDetailPageComponent, + UserEditBudgetPageComponent, UserEditProfilePageComponent, UserEditRolesPageComponent, UserProfileFormComponent, diff --git a/projects/organization-management/src/app/pages/organization-management-routing.module.ts b/projects/organization-management/src/app/pages/organization-management-routing.module.ts index 8273853581..90fe7e8797 100644 --- a/projects/organization-management/src/app/pages/organization-management-routing.module.ts +++ b/projects/organization-management/src/app/pages/organization-management-routing.module.ts @@ -6,6 +6,7 @@ import { RedirectFirstToParentGuard } from '../guards/redirect-first-to-parent.g import { UserCreatePageComponent } from './user-create/user-create-page.component'; import { UserDetailPageComponent } from './user-detail/user-detail-page.component'; +import { UserEditBudgetPageComponent } from './user-edit-budget/user-edit-budget-page.component'; import { UserEditProfilePageComponent } from './user-edit-profile/user-edit-profile-page.component'; import { UserEditRolesPageComponent } from './user-edit-roles/user-edit-roles-page.component'; import { UsersPageComponent } from './users/users-page.component'; @@ -45,6 +46,11 @@ export const routes: Routes = [ component: UserEditRolesPageComponent, canActivate: [RedirectFirstToParentGuard], }, + { + path: 'users/:B2BCustomerLogin/budget', + component: UserEditBudgetPageComponent, + canActivate: [RedirectFirstToParentGuard], + }, ]; @NgModule({ diff --git a/projects/organization-management/src/app/pages/user-create/user-create-page.component.html b/projects/organization-management/src/app/pages/user-create/user-create-page.component.html index 832376d171..8d606fedf4 100644 --- a/projects/organization-management/src/app/pages/user-create/user-create-page.component.html +++ b/projects/organization-management/src/app/pages/user-create/user-create-page.component.html @@ -3,7 +3,11 @@

{{ 'account.user.new.heading' | translate }}

- + +
+ + {{ diff --git a/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts index 6a51468782..17bd3c0f5e 100644 --- a/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts +++ b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts @@ -1,14 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateModule } from '@ngx-translate/core'; -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { anything, instance, mock, when } from 'ts-mockito'; +import { ServerSettingPipe } from 'ish-core/pipes/server-setting.pipe'; + import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; import { B2bRole } from '../../models/b2b-role/b2b-role.model'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { UserDetailBudgetComponent } from './user-detail-budget/user-detail-budget.component'; import { UserDetailPageComponent } from './user-detail-page.component'; describe('User Detail Page Component', () => { @@ -38,7 +41,12 @@ describe('User Detail Page Component', () => { await TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [MockComponent(FaIconComponent), UserDetailPageComponent], + declarations: [ + MockComponent(FaIconComponent), + MockComponent(UserDetailBudgetComponent), + MockPipe(ServerSettingPipe), + UserDetailPageComponent, + ], providers: [{ provide: OrganizationManagementFacade, useFactory: () => instance(organizationManagementFacade) }], }).compileComponents(); }); diff --git a/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.html b/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.html new file mode 100644 index 0000000000..545dae4766 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.html @@ -0,0 +1,28 @@ + diff --git a/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.spec.ts b/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.spec.ts new file mode 100644 index 0000000000..c741dea562 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { anything, instance, mock, when } from 'ts-mockito'; + +import { ServerSettingPipe } from 'ish-core/pipes/server-setting.pipe'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { UserBudgetFormComponent } from '../../components/user-budget-form/user-budget-form.component'; +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { B2bUser } from '../../models/b2b-user/b2b-user.model'; + +import { UserEditBudgetPageComponent } from './user-edit-budget-page.component'; + +describe('User Edit Budget Page Component', () => { + let component: UserEditBudgetPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + const organizationManagementFacade = mock(OrganizationManagementFacade); + when(organizationManagementFacade.selectedUser$).thenReturn( + of({ + firstName: 'John', + lastName: 'Doe', + login: 'j.d@test.de', + roleIDs: ['APP_B2B_BUYER'], + userBudget: { + budget: { value: 500, currency: 'USD' }, + orderSpentLimit: { value: 9000, currency: 'USD' }, + remainingBudget: { value: 500, currency: 'USD' }, + budgetPeriod: 'monthly', + }, + } as B2bUser) + ); + when(organizationManagementFacade.usersLoading$).thenReturn(of(false)); + when(organizationManagementFacade.usersError$).thenReturn(of()); + when(organizationManagementFacade.setSelectedUserBudget(anything())).thenReturn(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, TranslateModule.forRoot()], + declarations: [ + MockComponent(ErrorMessageComponent), + MockComponent(LoadingComponent), + MockComponent(UserBudgetFormComponent), + MockPipe(ServerSettingPipe), + UserEditBudgetPageComponent, + ], + providers: [{ provide: OrganizationManagementFacade, useFactory: () => instance(organizationManagementFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserEditBudgetPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should submit a valid form when the user fills all required fields', () => { + fixture.detectChanges(); + + expect(component.formDisabled).toBeFalse(); + component.submitForm(); + + expect(component.formDisabled).toBeFalse(); + }); +}); diff --git a/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.ts b/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.ts new file mode 100644 index 0000000000..bcf348b2a1 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-edit-budget/user-edit-budget-page.component.ts @@ -0,0 +1,92 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { whenTruthy } from 'ish-core/utils/operators'; +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; +import { SpecialValidators } from 'ish-shared/forms/validators/special-validators'; + +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { UserBudget } from '../../models/user-budget/user-budget.model'; + +@Component({ + selector: 'ish-user-edit-budget-page', + templateUrl: './user-edit-budget-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserEditBudgetPageComponent implements OnInit, OnDestroy { + constructor(private fb: FormBuilder, private organizationManagementFacade: OrganizationManagementFacade) {} + selectedUser$: Observable; + loading$: Observable; + error$: Observable; + + budgetForm: FormGroup; + user: B2bUser; + submitted = false; + + private destroy$ = new Subject(); + + ngOnInit() { + this.loading$ = this.organizationManagementFacade.usersLoading$; + this.error$ = this.organizationManagementFacade.usersError$; + this.selectedUser$ = this.organizationManagementFacade.selectedUser$; + + this.selectedUser$.pipe(whenTruthy(), takeUntil(this.destroy$)).subscribe(user => { + this.user = user; + this.initForm(user); + }); + } + + initForm(user: B2bUser) { + this.budgetForm = this.fb.group({ + orderSpentLimit: [user.userBudget?.orderSpentLimit?.value || '', SpecialValidators.moneyAmount], + budget: [user.userBudget?.budget?.value || '', SpecialValidators.moneyAmount], + budgetPeriod: [ + !user.userBudget?.budgetPeriod || user.userBudget?.budgetPeriod === 'none' + ? 'weekly' + : user.userBudget.budgetPeriod, + ], + currency: [user.userBudget?.remainingBudget?.currency, Validators.required], + }); + } + + submitForm() { + if (this.budgetForm.invalid) { + this.submitted = true; + markAsDirtyRecursive(this.budgetForm); + return; + } + + const formValue = this.budgetForm.value; + + const budget: UserBudget = formValue + ? { + budget: formValue.budget + ? { value: formValue.budget, currency: formValue.currency, type: 'Money' } + : undefined, + budgetPeriod: formValue.budgetPeriod, + orderSpentLimit: formValue.orderSpentLimit + ? { + value: formValue.orderSpentLimit, + currency: formValue.currency, + type: 'Money', + } + : undefined, + } + : undefined; + + this.organizationManagementFacade.setSelectedUserBudget(budget); + } + + get formDisabled() { + return this.budgetForm.invalid && this.submitted; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/organization-management/src/app/pages/user-edit-roles/user-edit-roles-page.component.html b/projects/organization-management/src/app/pages/user-edit-roles/user-edit-roles-page.component.html index 3c9510ab52..751150002e 100644 --- a/projects/organization-management/src/app/pages/user-edit-roles/user-edit-roles-page.component.html +++ b/projects/organization-management/src/app/pages/user-edit-roles/user-edit-roles-page.component.html @@ -1,5 +1,5 @@
-

{{ 'account.user.update_role.heading' | translate }} - {{ user.firstName }} {{ user.lastName }}

+

{{ 'account.user.update_roles.heading' | translate }} - {{ user.firstName }} {{ user.lastName }}

diff --git a/projects/organization-management/src/app/pages/users/users-page.component.html b/projects/organization-management/src/app/pages/users/users-page.component.html index 1319644562..712cb9e122 100644 --- a/projects/organization-management/src/app/pages/users/users-page.component.html +++ b/projects/organization-management/src/app/pages/users/users-page.component.html @@ -11,18 +11,25 @@

-
+
{{ user.firstName }} {{ user.lastName }} {{ 'account.user.list.status.inactive' | translate }}
-
+
-
+
+ +
+
{ MockComponent(FaIconComponent), MockComponent(LoadingComponent), MockComponent(ModalDialogComponent), + MockComponent(UserBudgetComponent), MockComponent(UserRolesBadgesComponent), + MockPipe(ServerSettingPipe), UsersPageComponent, ], providers: [ diff --git a/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts index 9a9cfc46fa..342d841515 100644 --- a/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts +++ b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts @@ -160,7 +160,31 @@ describe('Organization Management Breadcrumb Service', () => { "text": "account.organization.user_management.user_detail.breadcrumb - John Doe", }, Object { - "key": "account.user.update_role.heading", + "key": "account.user.update_roles.heading", + }, + ] + `); + done(); + }); + }); + + it('should set breadcrumb for user budget edit page', done => { + store$.dispatch(loadUserSuccess({ user: { login: '1', firstName: 'John', lastName: 'Doe' } as B2bUser })); + router.navigateByUrl('/users/1/budget'); + + organizationManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.organization.user_management", + "link": "/my-account/users", + }, + Object { + "link": "/my-account/users/1", + "text": "account.organization.user_management.user_detail.breadcrumb - John Doe", + }, + Object { + "key": "account.user.update_budget.heading", }, ] `); diff --git a/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts index 8e045c4aee..a586799c4d 100644 --- a/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts +++ b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts @@ -29,12 +29,12 @@ export class OrganizationManagementBreadcrumbService { { key: 'account.organization.user_management', link: prefix + '/users' }, { key: 'account.user.breadcrumbs.new_user.text' }, ]); - } else if (/users\/:B2BCustomerLogin(\/(profile|roles))?$/.test(path)) { + } else if (/users\/:B2BCustomerLogin(\/(profile|roles|budget))?$/.test(path)) { return this.organizationManagementFacade.selectedUser$.pipe( whenTruthy(), withLatestFrom(this.translateService.get('account.organization.user_management.user_detail.breadcrumb')), map(([user, translation]) => - path.endsWith('profile') || path.endsWith('roles') + path.endsWith('profile') || path.endsWith('roles') || path.endsWith('budget') ? // edit user detail [ { key: 'account.organization.user_management', link: prefix + '/users' }, @@ -43,9 +43,7 @@ export class OrganizationManagementBreadcrumbService { link: `${prefix}/users/${user.login}`, }, { - key: path.endsWith('roles') - ? 'account.user.update_role.heading' - : 'account.user.update_profile.heading', + key: `account.user.update_${path.substr(path.lastIndexOf('/') + 1)}.heading`, }, ] : // user detail diff --git a/projects/organization-management/src/app/services/users/users.service.spec.ts b/projects/organization-management/src/app/services/users/users.service.spec.ts index f496f03262..0f94cb84dc 100644 --- a/projects/organization-management/src/app/services/users/users.service.spec.ts +++ b/projects/organization-management/src/app/services/users/users.service.spec.ts @@ -9,6 +9,7 @@ import { getLoggedInCustomer } from 'ish-core/store/customer/user'; import { B2bRoleData } from '../../models/b2b-role/b2b-role.interface'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { UserBudget } from '../../models/user-budget/user-budget.model'; import { UsersService } from './users.service'; @@ -119,4 +120,50 @@ describe('Users Service', () => { done(); }); }); + + it('should put the budget onto user when calling setUserBudget', done => { + when(apiService.put(anyString(), anything())).thenReturn(of({})); + + usersService + .setUserBudget('pmiller@test.intershop.de', { + orderSpentLimit: undefined, + budget: { value: 2000, currency: 'USD' }, + budgetPeriod: 'monthly', + } as UserBudget) + .subscribe(data => { + expect(data).toMatchInlineSnapshot(`Object {}`); + verify(apiService.put(anything(), anything())).once(); + expect(capture(apiService.put).last()).toMatchInlineSnapshot(` + Array [ + "customers/4711/users/pmiller@test.intershop.de/budgets", + Object { + "budget": Object { + "currency": "USD", + "value": 2000, + }, + "budgetPeriod": "monthly", + "orderSpentLimit": undefined, + }, + ] + `); + done(); + }); + }); + + it('should call get of apiService to get budget for current user and set empty spentBudget', done => { + when(apiService.b2bUserEndpoint()).thenReturn(instance(apiService)); + when(apiService.get('budgets')).thenReturn(of({})); + + usersService.getCurrentUserBudget().subscribe(userBudget => { + verify(apiService.get('budgets')).once(); + expect(userBudget.spentBudget).toMatchInlineSnapshot(` + Object { + "currency": undefined, + "type": "Money", + "value": 0, + } + `); + done(); + }); + }); }); diff --git a/projects/organization-management/src/app/services/users/users.service.ts b/projects/organization-management/src/app/services/users/users.service.ts index c743c50015..16add56bda 100644 --- a/projects/organization-management/src/app/services/users/users.service.ts +++ b/projects/organization-management/src/app/services/users/users.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; -import { Observable, forkJoin, throwError } from 'rxjs'; +import { Observable, forkJoin, of, throwError } from 'rxjs'; import { concatMap, map, switchMap, take } from 'rxjs/operators'; +import { PriceHelper } from 'ish-core/models/price/price.helper'; import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; import { getLoggedInCustomer } from 'ish-core/store/customer/user'; import { whenTruthy } from 'ish-core/utils/operators'; @@ -12,6 +13,7 @@ import { B2bRoleMapper } from '../../models/b2b-role/b2b-role.mapper'; import { B2bRole } from '../../models/b2b-role/b2b-role.model'; import { B2bUserMapper } from '../../models/b2b-user/b2b-user.mapper'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { UserBudget } from '../../models/user-budget/user-budget.model'; @Injectable({ providedIn: 'root' }) export class UsersService { @@ -70,12 +72,21 @@ export class UsersService { preferredInvoiceToAddressUrn: undefined, preferredShipToAddressUrn: undefined, preferredPaymentInstrumentId: undefined, + userBudgets: undefined, + roleIds: undefined, }, ], }) .pipe( - concatMap(() => forkJoin([this.setUserRoles(user.email, user.roleIDs), this.getUser(user.email)])), - map(([roleIDs, newUser]) => ({ ...newUser, roleIDs })) + concatMap(() => + this.setUserRoles(user.email, user.roleIDs).pipe( + concatMap(roleIDs => + forkJoin([this.setUserBudget(user.email, user.userBudget), this.getUser(user.email)]).pipe( + map(([userBudget, newUser]) => ({ ...newUser, userBudget, roleIDs })) + ) + ) + ) + ) ) ) ); @@ -150,4 +161,34 @@ export class UsersService { ) ); } + + /** + * Set the budget for a given b2b user. + * @param login The login of the user. + * @param budget The user's budget. + * @returns The new budget + */ + setUserBudget(login: string, budget: UserBudget): Observable { + if (!budget) { + // tslint:disable-next-line: ish-no-object-literal-type-assertion + return of({} as UserBudget); + } + return this.currentCustomer$.pipe( + switchMap(customer => + this.apiService.put(`customers/${customer.customerNo}/users/${login}/budgets`, budget) + ) + ); + } + + getCurrentUserBudget(): Observable { + return this.apiService + .b2bUserEndpoint() + .get('budgets') + .pipe( + map(budget => ({ + ...budget, + spentBudget: budget.spentBudget ?? PriceHelper.empty(budget.budget?.currency), + })) + ); + } } diff --git a/projects/organization-management/src/app/store/budget/budget.actions.ts b/projects/organization-management/src/app/store/budget/budget.actions.ts new file mode 100644 index 0000000000..ce2b223f55 --- /dev/null +++ b/projects/organization-management/src/app/store/budget/budget.actions.ts @@ -0,0 +1,11 @@ +import { createAction } from '@ngrx/store'; + +import { httpError, payload } from 'ish-core/utils/ngrx-creators'; + +import { UserBudget } from '../../models/user-budget/user-budget.model'; + +export const loadBudget = createAction('[Budget Internal] Load Budget'); + +export const loadBudgetSuccess = createAction('[Budget API] Load Budget Success', payload<{ budget: UserBudget }>()); + +export const loadBudgetFail = createAction('[Budget API] Load Budget Fail', httpError()); diff --git a/projects/organization-management/src/app/store/budget/budget.effects.spec.ts b/projects/organization-management/src/app/store/budget/budget.effects.spec.ts new file mode 100644 index 0000000000..4a273dec55 --- /dev/null +++ b/projects/organization-management/src/app/store/budget/budget.effects.spec.ts @@ -0,0 +1,65 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { cold, hot } from 'jest-marbles'; +import { Observable, of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { Price } from 'ish-core/models/price/price.model'; + +import { UsersService } from '../../services/users/users.service'; + +import { loadBudget, loadBudgetSuccess } from './budget.actions'; +import { BudgetEffects } from './budget.effects'; + +const budget = { + orderSpentLimit: { + currency: 'USD', + value: 500, + } as Price, + remainingBudget: { + currency: 'USD', + value: 8110.13, + } as Price, + spentBudget: { + currency: 'USD', + value: 1889.87, + } as Price, + budget: { + currency: 'USD', + value: 10000, + } as Price, + budgetPeriod: 'monthly', +}; + +describe('Budget Effects', () => { + let actions$: Observable; + let effects: BudgetEffects; + let usersService: UsersService; + + beforeEach(() => { + usersService = mock(UsersService); + when(usersService.getCurrentUserBudget()).thenReturn(of(budget)); + + TestBed.configureTestingModule({ + providers: [ + BudgetEffects, + provideMockActions(() => actions$), + { provide: UsersService, useFactory: () => instance(usersService) }, + ], + }); + + effects = TestBed.inject(BudgetEffects); + }); + + describe('loadBudget$', () => { + it('should dispatch actions when encountering loadBudget', () => { + const action = loadBudget(); + const response = loadBudgetSuccess({ budget }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-b-b-b', { b: response }); + + expect(effects.loadBudget$).toBeObservable(expected$); + }); + }); +}); diff --git a/projects/organization-management/src/app/store/budget/budget.effects.ts b/projects/organization-management/src/app/store/budget/budget.effects.ts new file mode 100644 index 0000000000..3e38377edd --- /dev/null +++ b/projects/organization-management/src/app/store/budget/budget.effects.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { map, switchMap } from 'rxjs/operators'; + +import { mapErrorToAction } from 'ish-core/utils/operators'; + +import { UsersService } from '../../services/users/users.service'; + +import { loadBudget, loadBudgetFail, loadBudgetSuccess } from './budget.actions'; + +@Injectable() +export class BudgetEffects { + constructor(private actions$: Actions, private usersService: UsersService) {} + + loadBudget$ = createEffect(() => + this.actions$.pipe( + ofType(loadBudget), + switchMap(() => + this.usersService.getCurrentUserBudget().pipe( + map(budget => loadBudgetSuccess({ budget })), + mapErrorToAction(loadBudgetFail) + ) + ) + ) + ); +} diff --git a/projects/organization-management/src/app/store/budget/budget.reducer.ts b/projects/organization-management/src/app/store/budget/budget.reducer.ts new file mode 100644 index 0000000000..802886c908 --- /dev/null +++ b/projects/organization-management/src/app/store/budget/budget.reducer.ts @@ -0,0 +1,31 @@ +import { createReducer, on } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; + +import { UserBudget } from '../../models/user-budget/user-budget.model'; + +import { loadBudget, loadBudgetFail, loadBudgetSuccess } from './budget.actions'; + +export interface BudgetState { + budget: UserBudget; + loading: boolean; + error: HttpError; +} + +const initialState: BudgetState = { + loading: false, + budget: undefined, + error: undefined, +}; + +export const budgetReducer = createReducer( + initialState, + setLoadingOn(loadBudget), + unsetLoadingAndErrorOn(loadBudgetSuccess), + setErrorOn(loadBudgetFail), + on(loadBudgetSuccess, (state: BudgetState, { payload: { budget } }) => ({ + ...state, + budget, + })) +); diff --git a/projects/organization-management/src/app/store/budget/budget.selectors.spec.ts b/projects/organization-management/src/app/store/budget/budget.selectors.spec.ts new file mode 100644 index 0000000000..d2c6394f2d --- /dev/null +++ b/projects/organization-management/src/app/store/budget/budget.selectors.spec.ts @@ -0,0 +1,83 @@ +import { TestBed } from '@angular/core/testing'; + +import { Price } from 'ish-core/models/price/price.model'; +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; + +import { OrganizationManagementStoreModule } from '../organization-management-store.module'; + +import { loadBudget, loadBudgetFail, loadBudgetSuccess } from './budget.actions'; +import { getCurrentUserBudget, getCurrentUserBudgetError, getCurrentUserBudgetLoading } from './budget.selectors'; + +const budget = { + orderSpentLimit: { + currency: 'USD', + value: 500, + } as Price, + remainingBudget: { + currency: 'USD', + value: 8110.13, + } as Price, + spentBudget: { + currency: 'USD', + value: 1889.87, + } as Price, + budget: { + currency: 'USD', + value: 10000, + } as Price, + budgetPeriod: 'monthly', +}; + +describe('Budget Selectors', () => { + let store$: StoreWithSnapshots; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), OrganizationManagementStoreModule.forTesting('budget')], + providers: [provideStoreSnapshots()], + }); + + store$ = TestBed.inject(StoreWithSnapshots); + }); + + describe('initial state', () => { + it('should not be loading when in initial state', () => { + expect(getCurrentUserBudgetLoading(store$.state)).toBeFalse(); + }); + }); + + describe('loadBudget', () => { + const action = loadBudget(); + + beforeEach(() => { + store$.dispatch(action); + }); + + it('should set loading to true', () => { + expect(getCurrentUserBudgetLoading(store$.state)).toBeTrue(); + }); + }); + + describe('loadBudgetSuccess', () => { + beforeEach(() => { + store$.dispatch(loadBudgetSuccess({ budget })); + }); + + it('should add loaded budget to store', () => { + expect(getCurrentUserBudget(store$.state)).toEqual(budget); + }); + }); + + describe('loadBudgetFail', () => { + beforeEach(() => { + store$.dispatch(loadBudget()); + store$.dispatch(loadBudgetFail({ error: makeHttpError({ message: 'error' }) })); + }); + it('should set loading and error correctly on loadBudgetFail', () => { + expect(getCurrentUserBudgetError(store$.state)).toBeTruthy(); + expect(getCurrentUserBudgetLoading(store$.state)).toBeFalse(); + }); + }); +}); diff --git a/projects/organization-management/src/app/store/budget/budget.selectors.ts b/projects/organization-management/src/app/store/budget/budget.selectors.ts new file mode 100644 index 0000000000..9f134d1322 --- /dev/null +++ b/projects/organization-management/src/app/store/budget/budget.selectors.ts @@ -0,0 +1,11 @@ +import { createSelector } from '@ngrx/store'; + +import { getOrganizationManagementState } from '../organization-management-store'; + +const getBudgetState = createSelector(getOrganizationManagementState, state => state.budget); + +export const getCurrentUserBudgetLoading = createSelector(getBudgetState, state => state.loading); + +export const getCurrentUserBudgetError = createSelector(getBudgetState, state => state.error); + +export const getCurrentUserBudget = createSelector(getBudgetState, state => state.budget); diff --git a/projects/organization-management/src/app/store/budget/index.ts b/projects/organization-management/src/app/store/budget/index.ts new file mode 100644 index 0000000000..78e7085595 --- /dev/null +++ b/projects/organization-management/src/app/store/budget/index.ts @@ -0,0 +1,3 @@ +// API to access ngrx budget state +export * from './budget.actions'; +export * from './budget.selectors'; diff --git a/projects/organization-management/src/app/store/organization-management-store.module.ts b/projects/organization-management/src/app/store/organization-management-store.module.ts index 3c1dcd460b..9b87055232 100644 --- a/projects/organization-management/src/app/store/organization-management-store.module.ts +++ b/projects/organization-management/src/app/store/organization-management-store.module.ts @@ -5,13 +5,18 @@ import { pick } from 'lodash-es'; import { resetOnLogoutMeta } from 'ish-core/utils/meta-reducers'; +import { BudgetEffects } from './budget/budget.effects'; +import { budgetReducer } from './budget/budget.reducer'; import { OrganizationManagementState } from './organization-management-store'; import { UsersEffects } from './users/users.effects'; import { usersReducer } from './users/users.reducer'; -const organizationManagementReducers: ActionReducerMap = { users: usersReducer }; +const organizationManagementReducers: ActionReducerMap = { + users: usersReducer, + budget: budgetReducer, +}; -const organizationManagementEffects = [UsersEffects]; +const organizationManagementEffects = [UsersEffects, BudgetEffects]; const metaReducers = [resetOnLogoutMeta]; diff --git a/projects/organization-management/src/app/store/organization-management-store.ts b/projects/organization-management/src/app/store/organization-management-store.ts index 36175bfb08..23601407ae 100644 --- a/projects/organization-management/src/app/store/organization-management-store.ts +++ b/projects/organization-management/src/app/store/organization-management-store.ts @@ -1,9 +1,11 @@ import { createFeatureSelector } from '@ngrx/store'; +import { BudgetState } from './budget/budget.reducer'; import { UsersState } from './users/users.reducer'; export interface OrganizationManagementState { users: UsersState; + budget: BudgetState; } export const getOrganizationManagementState = createFeatureSelector( diff --git a/projects/organization-management/src/app/store/users/users.actions.ts b/projects/organization-management/src/app/store/users/users.actions.ts index a2e5aa8793..5d1ad5cbdd 100644 --- a/projects/organization-management/src/app/store/users/users.actions.ts +++ b/projects/organization-management/src/app/store/users/users.actions.ts @@ -4,6 +4,7 @@ import { httpError, payload } from 'ish-core/utils/ngrx-creators'; import { B2bRole } from '../../models/b2b-role/b2b-role.model'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { UserBudget } from '../../models/user-budget/user-budget.model'; export const loadUsers = createAction('[Users] Load Users'); @@ -27,7 +28,7 @@ export const updateUserFail = createAction('[Users API] Update User Fail', httpE export const updateUserSuccess = createAction('[Users API] Update User Success', payload<{ user: B2bUser }>()); -export const deleteUser = createAction('[Users API] Delete User', payload<{ login: string }>()); +export const deleteUser = createAction('[Users] Delete User', payload<{ login: string }>()); export const deleteUserFail = createAction('[Users API] Delete User Fail', httpError()); @@ -41,8 +42,20 @@ export const loadSystemUserRolesSuccess = createAction( export const setUserRoles = createAction('[Users] Set Roles for User', payload<{ login: string; roles: string[] }>()); export const setUserRolesSuccess = createAction( - '[Users] Set Roles for User Success', + '[Users API] Set Roles for User Success', payload<{ login: string; roles: string[] }>() ); -export const setUserRolesFail = createAction('[Users API] Set Roles for User Failed', httpError<{ login: string }>()); +export const setUserRolesFail = createAction('[Users API] Set Roles for User Fail', httpError<{ login: string }>()); + +export const setUserBudget = createAction( + '[Users] Set Budget for User', + payload<{ login: string; budget: UserBudget }>() +); + +export const setUserBudgetSuccess = createAction( + '[Users API] Set Budget for User Success', + payload<{ login: string; budget: UserBudget }>() +); + +export const setUserBudgetFail = createAction('[Users API] Set Budget for User Fail', httpError<{ login: string }>()); diff --git a/projects/organization-management/src/app/store/users/users.effects.spec.ts b/projects/organization-management/src/app/store/users/users.effects.spec.ts index 901e0202cd..9ebda89b66 100644 --- a/projects/organization-management/src/app/store/users/users.effects.spec.ts +++ b/projects/organization-management/src/app/store/users/users.effects.spec.ts @@ -7,7 +7,7 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { Action, Store } from '@ngrx/store'; import { cold, hot } from 'jest-marbles'; import { Observable, of, throwError } from 'rxjs'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { displaySuccessMessage } from 'ish-core/store/core/messages'; @@ -26,6 +26,9 @@ import { loadUsers, loadUsersFail, loadUsersSuccess, + setUserBudget, + setUserBudgetFail, + setUserBudgetSuccess, setUserRolesSuccess, updateUser, updateUserFail, @@ -37,7 +40,17 @@ import { UsersEffects } from './users.effects'; class DummyComponent {} const users = [ - { login: '1', firstName: 'Patricia', lastName: 'Miller', name: 'Patricia Miller' }, + { + login: '1', + firstName: 'Patricia', + lastName: 'Miller', + name: 'Patricia Miller', + budget: { + budget: { value: 500, currency: 'USD' }, + orderSpentLimit: { value: 9000, currency: 'USD' }, + budgetPeriod: 'monthly', + }, + }, { login: '2' }, ] as B2bUser[]; @@ -57,6 +70,7 @@ describe('Users Effects', () => { when(usersService.updateUser(anything())).thenReturn(of(users[0])); when(usersService.getUsers()).thenReturn(of(users)); when(usersService.deleteUser(anything())).thenReturn(of(true)); + when(usersService.setUserBudget(anyString(), anything())).thenReturn(of(users[0].userBudget)); TestBed.configureTestingModule({ declarations: [DummyComponent], @@ -211,10 +225,6 @@ describe('Users Effects', () => { const action = updateUser({ user: users[0] }); const completion = updateUserSuccess({ user: users[0] }); - // const completion2 = displaySuccessMessage({ - // message: 'account.organization.user_management.update_user.confirmation', - // messageParams: { 0: `${users[0].firstName} ${users[0].lastName}` }, - // }); actions$ = hot(' -a-a-a-|', { a: action }); const expected$ = cold('-c-c-c-|', { c: completion }); @@ -236,6 +246,40 @@ describe('Users Effects', () => { }); }); + describe('updateUserBudget$', () => { + it('should call the service for updating user budget', done => { + actions$ = of(setUserBudget({ login: users[0].login, budget: users[0].userBudget })); + + effects.setUserBudget$.subscribe(() => { + verify(usersService.setUserBudget(users[0].login, anything())).once(); + done(); + }); + }); + + it('should update user budget when triggered', () => { + const action = setUserBudget({ login: users[0].login, budget: users[0].userBudget }); + + const completion = setUserBudgetSuccess({ login: users[0].login, budget: users[0].userBudget }); + actions$ = hot(' -a-a-a-|', { a: action }); + const expected$ = cold('-c-c-c-|', { c: completion }); + + expect(effects.setUserBudget$).toBeObservable(expected$); + }); + + it('should dispatch an UpdateUserFail action on failed user update', () => { + const error = makeHttpError({ status: 401, code: 'feld' }); + when(usersService.setUserBudget(anyString(), anything())).thenReturn(throwError(error)); + + const action = setUserBudget({ login: users[0].login, budget: users[0].userBudget }); + const completion = setUserBudgetFail({ login: users[0].login, error }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-b', { b: completion }); + + expect(effects.setUserBudget$).toBeObservable(expected$); + }); + }); + describe('deleteUser$', () => { const login = 'pmiller@test.intershop.de'; @@ -296,6 +340,15 @@ describe('Users Effects', () => { expect(effects.successMessageAfterUpdate$).toBeObservable(expected$); }); + + it('should display success message after role update', () => { + const action = setUserBudgetSuccess({ login: '1', budget: undefined }); + + actions$ = hot(' -a-a-a-|', { a: action }); + const expected$ = cold('-c-c-c-|', { c: completion }); + + expect(effects.successMessageAfterUpdate$).toBeObservable(expected$); + }); }); describe('somewhere else', () => { diff --git a/projects/organization-management/src/app/store/users/users.effects.ts b/projects/organization-management/src/app/store/users/users.effects.ts index 61ee7936b1..2812d04768 100644 --- a/projects/organization-management/src/app/store/users/users.effects.ts +++ b/projects/organization-management/src/app/store/users/users.effects.ts @@ -25,6 +25,9 @@ import { loadUsers, loadUsersFail, loadUsersSuccess, + setUserBudget, + setUserBudgetFail, + setUserBudgetSuccess, setUserRoles, setUserRolesFail, setUserRolesSuccess, @@ -131,7 +134,7 @@ export class UsersEffects { this.usersService.setUserRoles(login, roles).pipe( withLatestFrom(this.store.pipe(select(selectPath))), tap(([, path]) => { - if (path.endsWith('users/:B2BCustomerLogin/roles')) { + if (path?.endsWith('users/:B2BCustomerLogin/roles')) { this.navigateTo('../../' + login); } }), @@ -142,15 +145,31 @@ export class UsersEffects { ) ); - successMessageAfterUpdate$ = createEffect(() => + updateCurrentUserRoles$ = createEffect(() => this.actions$.pipe( - ofType(updateUserSuccess, setUserRolesSuccess), - withLatestFrom(this.store.pipe(select(getSelectedUser), whenTruthy())), - map(([, user]) => - displaySuccessMessage({ - message: 'account.organization.user_management.update_user.confirmation', - messageParams: { 0: `${user.firstName} ${user.lastName}` }, - }) + ofType(setUserRolesSuccess), + mapToPayloadProperty('login'), + withLatestFrom(this.store.pipe(select(getLoggedInUser))), + filter(([login, currentUser]) => login === currentUser.login), + mapTo(loadRolesAndPermissions()) + ) + ); + + setUserBudget$ = createEffect(() => + this.actions$.pipe( + ofType(setUserBudget), + mapToPayload(), + mergeMap(({ login, budget }) => + this.usersService.setUserBudget(login, budget).pipe( + withLatestFrom(this.store.pipe(select(selectPath))), + tap(([, path]) => { + if (path?.endsWith('users/:B2BCustomerLogin/budget')) { + this.navigateTo('../../' + login); + } + }), + map(([newBudget]) => setUserBudgetSuccess({ login, budget: newBudget })), + mapErrorToAction(setUserBudgetFail, { login }) + ) ) ) ); @@ -165,9 +184,9 @@ export class UsersEffects { ) ); - updateCurrentUserRoles$ = createEffect(() => + updateCurrentUserBudget$ = createEffect(() => this.actions$.pipe( - ofType(setUserRolesSuccess), + ofType(setUserBudgetSuccess), mapToPayloadProperty('login'), withLatestFrom(this.store.pipe(select(getLoggedInUser))), filter(([login, currentUser]) => login === currentUser.login), @@ -175,6 +194,19 @@ export class UsersEffects { ) ); + successMessageAfterUpdate$ = createEffect(() => + this.actions$.pipe( + ofType(updateUserSuccess, setUserRolesSuccess, setUserBudgetSuccess), + withLatestFrom(this.store.pipe(select(getSelectedUser), whenTruthy())), + map(([, user]) => + displaySuccessMessage({ + message: 'account.organization.user_management.update_user.confirmation', + messageParams: { 0: `${user.firstName} ${user.lastName}` }, + }) + ) + ) + ); + deleteUser$ = createEffect(() => this.actions$.pipe( ofType(deleteUser), diff --git a/projects/organization-management/src/app/store/users/users.reducer.ts b/projects/organization-management/src/app/store/users/users.reducer.ts index 2301b6b3ed..e6eb2627ca 100644 --- a/projects/organization-management/src/app/store/users/users.reducer.ts +++ b/projects/organization-management/src/app/store/users/users.reducer.ts @@ -2,7 +2,7 @@ import { EntityState, createEntityAdapter } from '@ngrx/entity'; import { createReducer, on } from '@ngrx/store'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; -import { setErrorOn, setLoadingOn } from 'ish-core/utils/ngrx-creators'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; import { B2bRole } from '../../models/b2b-role/b2b-role.model'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; @@ -20,6 +20,9 @@ import { loadUsers, loadUsersFail, loadUsersSuccess, + setUserBudget, + setUserBudgetFail, + setUserBudgetSuccess, setUserRolesFail, setUserRolesSuccess, updateUser, @@ -45,8 +48,17 @@ const initialState: UsersState = usersAdapter.getInitialState({ export const usersReducer = createReducer( initialState, - setLoadingOn(loadUsers, addUser, updateUser, deleteUser), - setErrorOn(loadUsersFail, loadUserFail, addUserFail, updateUserFail, deleteUserFail, setUserRolesFail), + setLoadingOn(loadUsers, addUser, updateUser, deleteUser, setUserBudget), + unsetLoadingAndErrorOn(loadUsersSuccess, addUserSuccess, updateUserSuccess, deleteUserSuccess, setUserBudgetSuccess), + setErrorOn( + loadUsersFail, + loadUserFail, + addUserFail, + updateUserFail, + deleteUserFail, + setUserRolesFail, + setUserBudgetFail + ), on(loadUsersSuccess, (state: UsersState, action) => { const { users } = action.payload; @@ -54,8 +66,6 @@ export const usersReducer = createReducer( ...usersAdapter.upsertMany(users, state), // preserve order from API ids: users.map(u => u.login), - loading: false, - error: undefined, }; }), on(loadUserSuccess, (state: UsersState, action) => { @@ -72,8 +82,6 @@ export const usersReducer = createReducer( return { ...usersAdapter.addOne(user, state), - loading: false, - error: undefined, }; }), on(updateUserSuccess, (state: UsersState, action) => { @@ -81,8 +89,6 @@ export const usersReducer = createReducer( return { ...usersAdapter.upsertOne(user, state), - loading: false, - error: undefined, }; }), on(deleteUserSuccess, (state: UsersState, action) => { @@ -90,8 +96,6 @@ export const usersReducer = createReducer( return { ...usersAdapter.removeOne(login, state), - loading: false, - error: undefined, }; }), on(loadSystemUserRolesSuccess, (state, action) => ({ @@ -100,5 +104,8 @@ export const usersReducer = createReducer( })), on(setUserRolesSuccess, (state, action) => usersAdapter.updateOne({ id: action.payload.login, changes: { roleIDs: action.payload.roles } }, state) - ) + ), + on(setUserBudgetSuccess, (state, action) => ({ + ...usersAdapter.updateOne({ id: action.payload.login, changes: { userBudget: action.payload.budget } }, state), + })) ); diff --git a/projects/requisition-management/src/app.component.html b/projects/requisition-management/src/app.component.html new file mode 100644 index 0000000000..e6398ae296 --- /dev/null +++ b/projects/requisition-management/src/app.component.html @@ -0,0 +1,21 @@ + diff --git a/projects/requisition-management/src/app.component.ts b/projects/requisition-management/src/app.component.ts new file mode 100644 index 0000000000..3f8f3ed04f --- /dev/null +++ b/projects/requisition-management/src/app.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { User } from 'ish-core/models/user/user.model'; + +@Component({ + selector: 'ish-requisition-management-root', + templateUrl: './app.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// tslint:disable-next-line: component-creation-test +export class AppComponent implements OnInit { + user$: Observable; + + constructor(private accountFacade: AccountFacade) {} + + ngOnInit() { + this.user$ = this.accountFacade.user$; + } +} diff --git a/projects/requisition-management/src/app.module.ts b/projects/requisition-management/src/app.module.ts new file mode 100644 index 0000000000..419f8153c8 --- /dev/null +++ b/projects/requisition-management/src/app.module.ts @@ -0,0 +1,47 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; + +import { CoreModule } from 'ish-core/core.module'; +import { AuthGuard } from 'ish-core/guards/auth.guard'; +import { IdentityProviderLogoutGuard } from 'ish-core/guards/identity-provider-logout.guard'; +import { FormsSharedModule } from 'ish-shared/forms/forms.module'; + +import { AppComponent } from './app.component'; +import { LoginComponent } from './login.component'; + +@NgModule({ + declarations: [AppComponent, LoginComponent], + imports: [ + BrowserModule, + CoreModule, + FormsSharedModule, + NoopAnimationsModule, + RouterModule.forRoot([ + { + path: 'login', + component: LoginComponent, + }, + { + path: 'logout', + canActivate: [IdentityProviderLogoutGuard], + component: LoginComponent, + }, + { + path: 'requisition-management', + loadChildren: () => import('./app/requisition-management.module').then(m => m.RequisitionManagementModule), + canActivate: [AuthGuard], + canActivateChild: [AuthGuard], + }, + { + path: '**', + redirectTo: 'requisition-management', + pathMatch: 'full', + }, + ]), + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.html b/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.html new file mode 100644 index 0000000000..72b518ec99 --- /dev/null +++ b/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.html @@ -0,0 +1,38 @@ + +
+
+
+
+
+ {{ numPendingApprovals$ | async }} +
+ {{ 'account.requisitions.widget.pending' | translate }} +
+
+
+ {{ 'account.requisitions.widget.order_total' | translate }} +
+ {{ amount | ishPrice: 'gross' }} +
+
+
+
+ + +
+
+
+ + + + diff --git a/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.spec.ts b/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.spec.ts new file mode 100644 index 0000000000..ab2e94f1b6 --- /dev/null +++ b/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.spec.ts @@ -0,0 +1,121 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { Price } from 'ish-core/models/price/price.model'; +import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +import { ApprovalWidgetComponent } from './approval-widget.component'; + +describe('Approval Widget Component', () => { + let component: ApprovalWidgetComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + let requisitionManagementFacade: RequisitionManagementFacade; + + const requisitions = [ + { + id: '4711', + requisitionNo: '4712', + approval: { + status: 'Approval Pending', + statusCode: 'pending', + customerApprovers: [ + { firstName: 'Jack', lastName: 'Link' }, + { firstName: 'Bernhhard', lastName: 'Boldner' }, + ], + }, + user: { firstName: 'Patricia', lastName: 'Miller' }, + totals: { + total: { + type: 'PriceItem', + gross: 1000, + net: 750, + currency: 'USD', + }, + }, + creationDate: 24324321, + lineItemCount: 2, + lineItems: undefined, + } as Requisition, + { + id: '4712', + requisitionNo: '4713', + approval: { + status: 'Approval Pending', + statusCode: 'pending', + customerApprovers: [ + { firstName: 'Jack', lastName: 'Link' }, + { firstName: 'Bernhhard', lastName: 'Boldner' }, + ], + }, + user: { firstName: 'Patricia', lastName: 'Miller' }, + totals: { + total: { + type: 'PriceItem', + gross: 1000, + net: 750, + currency: 'USD', + }, + }, + creationDate: 24324321, + lineItemCount: 2, + lineItems: undefined, + } as Requisition, + ]; + + beforeEach(async () => { + requisitionManagementFacade = mock(RequisitionManagementFacade); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ + ApprovalWidgetComponent, + MockComponent(InfoBoxComponent), + MockComponent(LoadingComponent), + MockPipe(PricePipe, (price: Price) => `${price.currency} ${price.value}`), + ], + providers: [{ provide: RequisitionManagementFacade, useFactory: () => instance(requisitionManagementFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ApprovalWidgetComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(requisitionManagementFacade.requisitions$('approver', 'PENDING')).thenReturn(of(requisitions)); + when(requisitionManagementFacade.requisitionsLoading$).thenReturn(of(false)); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should render loading component if approvals loading', () => { + when(requisitionManagementFacade.requisitionsLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); + + it('should display right amount of approvals', () => { + fixture.detectChanges(); + const pendingCounter = element.querySelector('[data-testing-id="pending-counter"]'); + expect(pendingCounter.textContent.trim()).toEqual('2'); + }); + + it('should display right sum of approval order amounts', () => { + fixture.detectChanges(); + const pendingAmountSum = element.querySelector('[data-testing-id="pending-amount-sum"]'); + expect(pendingAmountSum.textContent.trim()).toContain('2000'); + }); +}); diff --git a/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.ts b/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.ts new file mode 100644 index 0000000000..c9b6158f17 --- /dev/null +++ b/projects/requisition-management/src/app/components/approval-widget/approval-widget.component.ts @@ -0,0 +1,49 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + +import { PriceItemHelper } from 'ish-core/models/price-item/price-item.helper'; +import { Price, PriceHelper } from 'ish-core/models/price/price.model'; +import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-approval-widget', + templateUrl: './approval-widget.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +@GenerateLazyComponent() +export class ApprovalWidgetComponent implements OnInit { + numPendingApprovals$: Observable; + totalAmountApprovals$: Observable; + + requisitionsLoading$: Observable; + + constructor(private requisitionFacade: RequisitionManagementFacade) {} + + ngOnInit() { + const pendingApprovals$ = this.requisitionFacade.requisitions$('approver', 'PENDING'); + + this.numPendingApprovals$ = pendingApprovals$.pipe( + startWith([] as Requisition[]), + map(reqs => reqs.length) + ); + this.totalAmountApprovals$ = pendingApprovals$.pipe( + map(reqs => reqs?.map(req => PriceItemHelper.selectType(req.totals?.total, 'gross'))), + map(prices => { + if (prices.length > 0) { + return prices?.reduce( + (curr: Price, acc: Price) => PriceHelper.sum(curr, acc), + PriceHelper.empty(prices[0].currency ?? undefined) + ); + } else { + return PriceHelper.empty(); + } + }) + ); + + this.requisitionsLoading$ = this.requisitionFacade.requisitionsLoading$; + } +} diff --git a/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.html b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.html new file mode 100644 index 0000000000..4c8f47957c --- /dev/null +++ b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.html @@ -0,0 +1,27 @@ +
+
+ {{ overflowPercentage / 100 | percent }} +
+
+
+ + +
diff --git a/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.scss b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.scss new file mode 100644 index 0000000000..a7e93abcda --- /dev/null +++ b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.scss @@ -0,0 +1,49 @@ +.budget-bar { + position: relative; + display: flex; + height: 25px; + overflow: hidden; + font-size: inherit; + line-height: 0; + background-color: #fff; + + .budget-bar-used { + display: flex; + flex-direction: column; + justify-content: center; + color: #fff; + text-align: center; + } + + .budget-bar-used-additional { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + } +} + +.budget-bar-overflow { + position: relative; + + .overflow-indicator { + position: absolute; + z-index: 2; + height: 30px; + margin-top: -9px; + background: transparent (url('/assets/img/budget-bar-indicator.png')) 100% -8px no-repeat; + + .overflow-display { + float: right; + margin-top: -18px; + margin-right: -17px; + visibility: hidden; + } + } +} diff --git a/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.spec.ts b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.spec.ts new file mode 100644 index 0000000000..99823c858a --- /dev/null +++ b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.spec.ts @@ -0,0 +1,108 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { PricePipe } from 'ish-core/models/price/price.pipe'; + +import { BudgetBarComponent } from './budget-bar.component'; + +describe('Budget Bar Component', () => { + let component: BudgetBarComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + const accountFacade = mock(AccountFacade); + when(accountFacade.userPriceDisplayType$).thenReturn(of('gross')); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [BudgetBarComponent, PricePipe], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }) // tslint:disable-next-line: no-any + .configureCompiler({ preserveWhitespaces: true } as any) + .compileComponents(); + }); + + beforeEach(() => { + const translate = TestBed.inject(TranslateService); + translate.use('en'); + + fixture = TestBed.createComponent(BudgetBarComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + component.budget = { + type: 'Money', + currency: 'USD', + value: 500, + }; + + component.spentBudget = { + type: 'Money', + currency: 'USD', + value: 700, + }; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should not display anything if there is no budget given', () => { + component.budget = undefined; + + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(`N/A`); + }); + + it('should display budget bar if there is a budget given', () => { + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` +
+
+ 71% +
+
+
+ +
+ `); + }); + + it('should display the additional budget if given', () => { + component.additionalAmount = { + type: 'Money', + currency: 'USD', + value: 300, + }; + + component.ngOnChanges(); + fixture.detectChanges(); + expect(element.querySelector('.budget-bar-used-additional')).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.ts b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.ts new file mode 100644 index 0000000000..02b181bcac --- /dev/null +++ b/projects/requisition-management/src/app/components/budget-bar/budget-bar.component.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +import { Price } from 'ish-core/models/price/price.model'; + +/** + * The budget bar visualizes the current and spent budget of a user. If an additional amount is defined, it will be displayed in addition to the spent budget. + * + * @example + * + */ + +@Component({ + selector: 'ish-budget-bar', + templateUrl: './budget-bar.component.html', + styleUrls: ['./budget-bar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BudgetBarComponent implements OnChanges { + @Input() budget: Price; + @Input() spentBudget?: Price; + @Input() additionalAmount?: Price; + + budgetPercentage: number; + overflowPercentage: number; + additionalPercentage: number; + + displayClass: string; + addDisplayClass: string; + + ngOnChanges() { + this.calculate(); + } + + /** calculates all displayed prices and percentages */ + private calculate() { + if (this.budget && this.budget.value) { + this.budgetPercentage = this.spentBudget?.value ? (this.spentBudget.value / this.budget.value) * 100 : 0; + this.displayClass = this.getDisplayColor(this.budgetPercentage); + + this.overflowPercentage = this.budgetPercentage > 100 ? (this.budget.value / this.spentBudget.value) * 100 : 0; + + this.additionalPercentage = this.overflowPercentage + ? (this.additionalAmount?.value / (this.spentBudget.value + this.additionalAmount?.value)) * 100 + : (this.additionalAmount?.value / this.budget.value) * 100; + + this.addDisplayClass = this.getDisplayColor(this.budgetPercentage + this.additionalPercentage); + } + } + + private getDisplayColor(percentage: number): string { + return percentage >= 90 ? 'bg-danger' : percentage >= 70 ? 'bg-warning' : 'bg-success'; + } +} 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 new file mode 100644 index 0000000000..666199dae2 --- /dev/null +++ b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.html @@ -0,0 +1,35 @@ +
+

+ {{ 'checkout.cart_for_approval.title' | translate }} +

+
+

+ {{ 'checkout.id_of_order.label' | translate + }}  + {{ req.requisitionNo }} + +

+ +

{{ 'approval.cart.order_has_been_submitted' | translate }}

+
+

+

+
+ +

+
+
+ +
+
+
diff --git a/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.spec.ts b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.spec.ts new file mode 100644 index 0000000000..78d45f4434 --- /dev/null +++ b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; +import { BasketApprovalInfoComponent } from 'ish-shared/components/basket/basket-approval-info/basket-approval-info.component'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +import { CheckoutReceiptRequisitionComponent } from './checkout-receipt-requisition.component'; + +describe('Checkout Receipt Requisition Component', () => { + let component: CheckoutReceiptRequisitionComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let reqFacade: RequisitionManagementFacade; + + beforeEach(async () => { + reqFacade = mock(RequisitionManagementFacade); + await TestBed.configureTestingModule({ + declarations: [ + CheckoutReceiptRequisitionComponent, + MockComponent(BasketApprovalInfoComponent), + MockDirective(ServerHtmlDirective), + ], + imports: [RouterTestingModule, TranslateModule.forRoot()], + providers: [{ provide: RequisitionManagementFacade, useFactory: () => instance(reqFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CheckoutReceiptRequisitionComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + component.basket = BasketMockData.getBasket(); + + when(reqFacade.requisition$(component.basket.id)).thenReturn(of({ requisitionNo: 'req001' } as Requisition)); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display the document number after creation', () => { + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id="requisition-number"]').innerHTML.trim()).toContain('req001'); + }); +}); diff --git a/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.ts b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.ts new file mode 100644 index 0000000000..412d95dc60 --- /dev/null +++ b/projects/requisition-management/src/app/components/checkout-receipt-requisition/checkout-receipt-requisition.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { Basket } from 'ish-core/models/basket/basket.model'; +import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +@GenerateLazyComponent() +@Component({ + selector: 'ish-checkout-receipt-requisition', + templateUrl: './checkout-receipt-requisition.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckoutReceiptRequisitionComponent implements OnInit { + @Input() basket: Basket; + + requisition$: Observable; + + constructor(private requisitionManagementFacade: RequisitionManagementFacade) {} + + ngOnInit() { + if (this.basket) { + this.requisition$ = this.requisitionManagementFacade.requisition$(this.basket.id); + } + } +} diff --git a/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.html b/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.html new file mode 100644 index 0000000000..9492c8e8d9 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.html @@ -0,0 +1,78 @@ +
+ +
+
+
+
{{ 'approval.detailspage.buyer.label' | translate }}
+
{{ requisition.user.firstName }} {{ requisition.user.lastName }}
+
+
+
+
+
+
+
{{ 'approval.detailspage.order_spend_limit.label' | translate }}
+
+ + + {{ requisition.userBudget.orderSpentLimit | ishPrice }} + + + {{ 'account.budget.unlimited' | translate }} + + +
+
+
+
+ +
+
+
+
+
+
{{ budgetLabel | translate }}
+
+ + + {{ requisition.userBudget.budget | ishPrice }} + + +
+
+
+
{{ 'account.budget.already_spent.label' | translate }}
+
+ {{ requisition.userBudget.spentBudget | ishPrice }} ({{ spentPercentage | percent }}) +
+
+
+
{{ 'approval.detailspage.budget.including_order.label' | translate }}
+
+ {{ requisition.userBudget?.spentBudgetIncludingThisRequisition | ishPrice }} ({{ + spentPercentageIncludingThisRequisition | percent + }}) +
+
+ +
+
{{ 'account.budget.left.label' | translate }}
+
+ {{ this.requisition.userBudget?.remainingBudget | ishPrice }} ({{ leftPercentage | percent }}) +
+
+
+
+
+ +
+
+
+
diff --git a/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.spec.ts b/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.spec.ts new file mode 100644 index 0000000000..6e218d3878 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.spec.ts @@ -0,0 +1,115 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { BasketTotal } from 'ish-core/models/basket-total/basket-total.model'; +import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; + +import { Requisition } from '../../models/requisition/requisition.model'; +import { BudgetBarComponent } from '../budget-bar/budget-bar.component'; + +import { RequisitionBuyerApprovalComponent } from './requisition-buyer-approval.component'; + +describe('Requisition Buyer Approval Component', () => { + let component: RequisitionBuyerApprovalComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + const accountFacade = mock(AccountFacade); + when(accountFacade.userPriceDisplayType$).thenReturn(of('gross')); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ + MockComponent(BudgetBarComponent), + MockComponent(InfoBoxComponent), + PricePipe, + RequisitionBuyerApprovalComponent, + ], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }) // tslint:disable-next-line: no-any + .configureCompiler({ preserveWhitespaces: true } as any) + .compileComponents(); + }); + + beforeEach(() => { + const translate = TestBed.inject(TranslateService); + translate.use('en'); + + fixture = TestBed.createComponent(RequisitionBuyerApprovalComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + component.requisition = { + id: 'testUUDI', + requisitionNo: '0001', + orderNo: '10001', + approval: { + statusCode: 'APPROVED', + approver: { firstName: 'Bernhard', lastName: 'Boldner' }, + approvalDate: 76543627, + }, + user: { firstName: 'Patricia', lastName: 'Miller', email: 'pmiller@test.intershop.de' }, + userBudget: { + budgetPeriod: 'weekly', + orderSpentLimit: { currency: 'USD', value: 500, type: 'Money' }, + spentBudget: { currency: 'USD', value: 300, type: 'Money' }, + budget: { currency: 'USD', value: 3000, type: 'Money' }, + remainingBudget: { currency: 'USD', value: 2700, type: 'Money' }, + remainingBudgetIncludingThisRequisition: { currency: 'USD', value: 700, type: 'Money' }, + spentBudgetIncludingThisRequisition: { currency: 'USD', value: 2300, type: 'Money' }, + }, + totals: { + total: { gross: 2000, net: 1800, currency: 'USD' }, + } as BasketTotal, + } as Requisition; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display budget information if created', () => { + component.ngOnChanges(); + fixture.detectChanges(); + expect(element.textContent.replace(/^\s*[\r\n]*/gm, '')).toMatchInlineSnapshot(` + "approval.detailspage.buyer.label + Patricia Miller + approval.detailspage.order_spend_limit.label + $500.00 + account.budget.type.weekly.label + $3,000.00 + account.budget.already_spent.label + $300.00 (10%) + account.budget.left.label + $2,700.00 (90%) + " + `); + }); + + it('should display budget including this order information if approval status pending', () => { + component.requisition.approval.statusCode = 'PENDING'; + component.ngOnChanges(); + fixture.detectChanges(); + expect(element.textContent.replace(/^\s*[\r\n]*/gm, '')).toMatchInlineSnapshot(` + "approval.detailspage.buyer.label + Patricia Miller + approval.detailspage.order_spend_limit.label + $500.00 + account.budget.type.weekly.label + $3,000.00 + account.budget.already_spent.label + $300.00 (10%) + approval.detailspage.budget.including_order.label + $2,300.00 (77%) + " + `); + }); +}); diff --git a/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.ts b/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.ts new file mode 100644 index 0000000000..6fa773336c --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-buyer-approval/requisition-buyer-approval.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +import { Price } from 'ish-core/models/price/price.model'; + +import { Requisition } from '../../models/requisition/requisition.model'; + +/** + * The buyer approval info box + * + */ +@Component({ + selector: 'ish-requisition-buyer-approval', + templateUrl: './requisition-buyer-approval.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RequisitionBuyerApprovalComponent implements OnChanges { + @Input() requisition: Requisition; + + orderTotal: Price; + spentPercentage: number; + spentPercentageIncludingThisRequisition: number; + leftPercentage: number; + budgetLabel: string; + + ngOnChanges() { + this.calculate(); + this.budgetLabel = `account.budget.type.${this.requisition?.userBudget?.budgetPeriod}.label`; + } + + /** calculates all displayed prices and percentages */ + private calculate() { + if (this.requisition) { + this.orderTotal = { + type: 'Money', + value: this.requisition.totals.total.gross, + currency: this.requisition.totals.total.currency, + }; + + this.spentPercentage = this.requisition.userBudget?.spentBudget?.value + ? this.requisition.userBudget.spentBudget.value / this.requisition.userBudget.budget.value + : 0; + this.leftPercentage = this.spentPercentage < 1 ? 1 - this.spentPercentage : 0; + + const spentBudgetIncludingThisOrder = this.requisition.userBudget?.spentBudgetIncludingThisRequisition; + + this.spentPercentageIncludingThisRequisition = + spentBudgetIncludingThisOrder?.value && this.requisition.userBudget?.budget?.value + ? spentBudgetIncludingThisOrder.value / this.requisition.userBudget?.budget.value + : 0; + } + } +} diff --git a/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.html b/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.html new file mode 100644 index 0000000000..dd381ced5a --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.html @@ -0,0 +1,43 @@ + + + diff --git a/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.spec.ts b/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.spec.ts new file mode 100644 index 0000000000..78c54463ef --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { spy, verify } from 'ts-mockito'; + +import { TextareaComponent } from 'ish-shared/forms/components/textarea/textarea.component'; + +import { RequisitionRejectDialogComponent } from './requisition-reject-dialog.component'; + +describe('Requisition Reject Dialog Component', () => { + let component: RequisitionRejectDialogComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MockComponent(TextareaComponent), RequisitionRejectDialogComponent], + imports: [ReactiveFormsModule, TranslateModule.forRoot()], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RequisitionRejectDialogComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should emit approval comment when submit form was called and the form was valid', done => { + fixture.detectChanges(); + component.rejectForm.setValue({ + comment: 'test comment', + }); + + component.submit.subscribe(emit => { + expect(emit).toEqual('test comment'); + done(); + }); + + component.submitForm(); + }); + + it('should not emit new approval comment when submit form was called and the form was invalid', () => { + fixture.detectChanges(); + const emitter = spy(component.submit); + component.submitForm(); + + verify(emitter.emit()).never(); + }); +}); diff --git a/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.ts b/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.ts new file mode 100644 index 0000000000..9c3c91740e --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-reject-dialog/requisition-reject-dialog.component.ts @@ -0,0 +1,77 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, TemplateRef, ViewChild } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; + +/** + * The Wishlist Reject Approval Dialog shows the modal to reject a requisition. + * + * @example + * + + */ +@Component({ + selector: 'ish-requisition-reject-dialog', + templateUrl: './requisition-reject-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RequisitionRejectDialogComponent { + /** + * Emits the reject event with the reject comment. + */ + @Output() submit = new EventEmitter(); + + rejectForm: FormGroup; + submitted = false; + + /** + * A reference to the current modal. + */ + modal: NgbModalRef; + + @ViewChild('modal') modalTemplate: TemplateRef; + + constructor(private ngbModal: NgbModal) { + this.initForm(); + } + + initForm() { + this.rejectForm = new FormGroup({ + comment: new FormControl('', Validators.required), + }); + } + + /** Emits the reject comment data, when the form was valid. */ + submitForm() { + if (this.rejectForm.valid) { + this.submit.emit(this.rejectForm.get('comment').value); + + this.hide(); + } else { + this.submitted = true; + markAsDirtyRecursive(this.rejectForm); + } + } + + /** Opens the modal. */ + show() { + this.modal = this.ngbModal.open(this.modalTemplate); + } + + /** Close the modal. */ + hide() { + this.rejectForm.reset({ + comment: '', + }); + this.submitted = false; + if (this.modal) { + this.modal.close(); + } + } + + get formDisabled() { + return this.rejectForm.invalid && this.submitted; + } +} diff --git a/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.html b/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.html new file mode 100644 index 0000000000..6a152113f9 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.html @@ -0,0 +1,70 @@ +
+
+
{{ 'approval.detailspage.order.request_id' | translate }}
+
{{ requisition.requisitionNo }}
+ + +
{{ 'approval.detailspage.order_reference_id.label' | translate }}
+
+ {{ + requisition.orderNo + }} + {{ requisition.orderNo }} +
+
+ +
{{ 'approval.detailspage.order_date.label' | translate }}
+
{{ requisition.creationDate | ishDate }}
+ + +
{{ 'approval.detailspage.approver.label' | translate }}
+
+ + {{ requisition.approval?.approver?.firstName }} {{ requisition.approval?.approver?.lastName }} + + + + , {{ approver.firstName }} {{ approver.lastName }} + + +
+
+ + +
{{ 'approval.detailspage.buyer.label' | translate }}
+
{{ requisition.user?.firstName }} {{ requisition.user?.lastName }}
+
+ + + +
{{ 'approval.detailspage.approval_date.label' | translate }}
+
+ +
{{ 'approval.detailspage.rejection_date.label' | translate }}
+
+
{{ requisition.approval.approvalDate | ishDate }}
+
+ +
{{ 'approval.detailspage.order_total.label' | translate }}
+
{{ requisition.totals?.total | ishPrice: 'gross' }}
+ +
{{ 'approval.detailspage.approval_status.label' | translate }}
+
+ + {{ requisition.approval.status }} +
+ + +
{{ 'approval.detailspage.comment.label' | translate }}
+
{{ requisition.approval.approvalComment }}
+
+
+
diff --git a/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.spec.ts b/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.spec.ts new file mode 100644 index 0000000000..21309f930f --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.spec.ts @@ -0,0 +1,100 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; + +import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { DatePipe } from 'ish-core/pipes/date.pipe'; + +import { Requisition } from '../../models/requisition/requisition.model'; + +import { RequisitionSummaryComponent } from './requisition-summary.component'; + +describe('Requisition Summary Component', () => { + let component: RequisitionSummaryComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, TranslateModule.forRoot()], + declarations: [MockPipe(DatePipe), MockPipe(PricePipe), RequisitionSummaryComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RequisitionSummaryComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + component.requisition = { + id: '4711', + requisitionNo: '4712', + approval: { + status: 'Approval Pending', + statusCode: 'PENDING', + customerApprovers: [ + { firstName: 'Jack', lastName: 'Link' }, + { firstName: 'Bernhhard', lastName: 'Boldner' }, + ], + }, + user: { firstName: 'Patricia', lastName: 'Miller' }, + totals: undefined, + creationDate: 24324321, + lineItemCount: 2, + lineItems: undefined, + } as Requisition; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should not display anything if there is no requisition given', () => { + component.requisition = undefined; + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(`N/A`); + }); + + it('should display buyer view if there is a requisition given', () => { + fixture.detectChanges(); + expect(element.querySelectorAll('dd')).toMatchInlineSnapshot(` + NodeList [ +
4712
, +
, +
Jack Link , Bernhhard Boldner
, +
, +
+ + Approval Pending +
, + ] + `); + }); + + it('should display approver view if there is a requisition given', () => { + component.view = 'approver'; + + fixture.detectChanges(); + expect(element.querySelectorAll('dd')).toMatchInlineSnapshot(` + NodeList [ +
4712
, +
, +
Patricia Miller
, +
, +
+ + Approval Pending +
, + ] + `); + }); +}); diff --git a/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.ts b/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.ts new file mode 100644 index 0000000000..5300f118a6 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-summary/requisition-summary.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { Requisition, RequisitionViewer } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-requisition-summary', + templateUrl: './requisition-summary.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RequisitionSummaryComponent { + @Input() requisition: Requisition; + @Input() view: RequisitionViewer = 'buyer'; +} diff --git a/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.html b/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.html new file mode 100644 index 0000000000..33c90cc71d --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.html @@ -0,0 +1,40 @@ + +
+
+
+
+
+ {{ numPendingRequisitions$ | async }} +
+ {{ 'account.requisitions.widget.pending' | translate }} +
+
+
+
+ {{ 'account.requisitions.widget.order_total' | translate }} +
+
+ {{ amount | ishPrice: 'gross' }} +
+
+
+
+ + +
+
+
+ + + + diff --git a/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.spec.ts b/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.spec.ts new file mode 100644 index 0000000000..a4ee3a2d69 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.spec.ts @@ -0,0 +1,121 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { Price } from 'ish-core/models/price/price.model'; +import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +import { RequisitionWidgetComponent } from './requisition-widget.component'; + +describe('Requisition Widget Component', () => { + let component: RequisitionWidgetComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + let requisitionManagementFacade: RequisitionManagementFacade; + + const requisitions = [ + { + id: '4711', + requisitionNo: '4712', + approval: { + status: 'Approval Pending', + statusCode: 'pending', + customerApprovers: [ + { firstName: 'Jack', lastName: 'Link' }, + { firstName: 'Bernhhard', lastName: 'Boldner' }, + ], + }, + user: { firstName: 'Patricia', lastName: 'Miller' }, + totals: { + total: { + type: 'PriceItem', + gross: 1000, + net: 750, + currency: 'USD', + }, + }, + creationDate: 24324321, + lineItemCount: 2, + lineItems: undefined, + } as Requisition, + { + id: '4712', + requisitionNo: '4713', + approval: { + status: 'Approval Pending', + statusCode: 'pending', + customerApprovers: [ + { firstName: 'Jack', lastName: 'Link' }, + { firstName: 'Bernhhard', lastName: 'Boldner' }, + ], + }, + user: { firstName: 'Patricia', lastName: 'Miller' }, + totals: { + total: { + type: 'PriceItem', + gross: 1000, + net: 750, + currency: 'USD', + }, + }, + creationDate: 24324321, + lineItemCount: 2, + lineItems: undefined, + } as Requisition, + ]; + + beforeEach(async () => { + requisitionManagementFacade = mock(RequisitionManagementFacade); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ + MockComponent(InfoBoxComponent), + MockComponent(LoadingComponent), + MockPipe(PricePipe, (price: Price) => `${price.currency} ${price.value}`), + RequisitionWidgetComponent, + ], + providers: [{ provide: RequisitionManagementFacade, useFactory: () => instance(requisitionManagementFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RequisitionWidgetComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(requisitionManagementFacade.requisitions$('buyer', 'PENDING')).thenReturn(of(requisitions)); + when(requisitionManagementFacade.requisitionsLoading$).thenReturn(of(false)); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should render loading component if requisitions loading', () => { + when(requisitionManagementFacade.requisitionsLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); + + it('should display right amount of approvals', () => { + fixture.detectChanges(); + const pendingCounter = element.querySelector('[data-testing-id="pending-counter"]'); + expect(pendingCounter.textContent.trim()).toEqual('2'); + }); + + it('should display right sum of approval order amounts', () => { + fixture.detectChanges(); + const pendingAmountSum = element.querySelector('[data-testing-id="pending-amount-sum"]'); + expect(pendingAmountSum.textContent.trim()).toContain('2000'); + }); +}); diff --git a/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.ts b/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.ts new file mode 100644 index 0000000000..bcb2979326 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisition-widget/requisition-widget.component.ts @@ -0,0 +1,49 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + +import { PriceItemHelper } from 'ish-core/models/price-item/price-item.helper'; +import { Price, PriceHelper } from 'ish-core/models/price/price.model'; +import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-requisition-widget', + templateUrl: './requisition-widget.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +@GenerateLazyComponent() +export class RequisitionWidgetComponent implements OnInit { + numPendingRequisitions$: Observable; + totalAmountRequisitons$: Observable; + + requisitionsLoading$: Observable; + + constructor(private requisitionFacade: RequisitionManagementFacade) {} + + ngOnInit() { + const pendingRequisitions$ = this.requisitionFacade.requisitions$('buyer', 'PENDING'); + + this.numPendingRequisitions$ = pendingRequisitions$.pipe( + startWith([] as Requisition[]), + map(reqs => reqs.length) + ); + this.totalAmountRequisitons$ = pendingRequisitions$.pipe( + map(reqs => reqs?.map(req => PriceItemHelper.selectType(req.totals?.total, 'gross'))), + map(prices => { + if (prices.length > 0) { + return prices?.reduce( + (curr: Price, acc: Price) => PriceHelper.sum(curr, acc), + PriceHelper.empty(prices[0].currency ?? undefined) + ); + } else { + return PriceHelper.empty(); + } + }) + ); + + this.requisitionsLoading$ = this.requisitionFacade.requisitionsLoading$; + } +} 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 new file mode 100644 index 0000000000..bbd96ef6c5 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.html @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'account.approvallist.table.id_of_order' | translate }} + + {{ requisition.requisitionNo }} + + {{ 'account.approvallist.table.no_of_order' | translate }} + + {{ requisition.orderNo }} + + {{ 'account.approvallist.table.no_of_order' | translate }} + + {{ requisition.orderNo }} + + {{ 'account.approvallist.table.date_of_order' | translate }} + + {{ requisition.creationDate | ishDate }} + + {{ 'account.approvallist.table.approver' | translate }} + + {{ requisition.approval?.approver?.firstName }} {{ requisition.approval?.approver?.lastName }} + + {{ 'account.approvallist.table.buyer' | translate }} + + {{ requisition.user.firstName }} {{ requisition.user.lastName }} + + {{ 'account.approvallist.table.approval_date' | translate }} + + {{ requisition.approval.approvalDate | ishDate }} + + {{ 'account.approvallist.table.reject_date' | translate }} + + {{ requisition.approval.approvalDate | ishDate }} + + {{ 'account.approvallist.table.line_items' | translate }} + + {{ requisition.lineItemCount }} + + {{ 'account.approvallist.table.line_item_total' | translate }} + + {{ requisition.totals.total | ishPrice: 'gross' }} +
+ +
+ {{ requisitions?.length | i18nPlural: ('account.approvallist.items' | translate) || { other: '#' } }} +
+
+ + +

{{ 'account.approvallist.no_items_message' | translate }}

+
diff --git a/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.spec.ts b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.spec.ts new file mode 100644 index 0000000000..82e8e9158c --- /dev/null +++ b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.spec.ts @@ -0,0 +1,131 @@ +import { CdkTableModule } from '@angular/cdk/table'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; + +import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { DatePipe } from 'ish-core/pipes/date.pipe'; + +import { Requisition } from '../../models/requisition/requisition.model'; + +import { RequisitionsListComponent } from './requisitions-list.component'; + +describe('Requisitions List Component', () => { + let component: RequisitionsListComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let translate: TranslateService; + const requisitions = [ + { + id: '0123', + requisitionNo: '0001', + orderNo: '10001', + approval: { + approvalDate: 76543627, + }, + user: { firstName: 'Patricia', lastName: 'Miller', email: 'pmiller@test.intershop.de' }, + totals: {}, + }, + { + id: '0124', + requisitionNo: '0002', + orderNo: '10002', + approval: { + approvalDate: 76543628, + }, + user: { firstName: 'Patricia', lastName: 'Miller', email: 'pmiller@test.intershop.de' }, + totals: {}, + }, + ] as Requisition[]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CdkTableModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [MockPipe(DatePipe), MockPipe(PricePipe), RequisitionsListComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RequisitionsListComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + translate = TestBed.inject(TranslateService); + translate.setDefaultLang('en'); + translate.use('en'); + translate.setTranslation('en', { + 'account.approvallist.items': { other: '# items' }, + }); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display empty list text if there are no requisitions', () => { + component.requisitions = []; + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=emptyList]')).toBeTruthy(); + }); + + it('should display a list of requisitions if there are requisitions', () => { + component.requisitions = requisitions; + component.columnsToDisplay = ['requisitionNo', 'orderTotal']; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=requisition-list]')).toBeTruthy(); + expect(element.querySelectorAll('[data-testing-id=requisition-list] td')).toHaveLength(4); + }); + + it('should display list counter if there are requisitions', () => { + component.requisitions = requisitions; + component.columnsToDisplay = ['requisitionNo', 'orderTotal']; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=list-counter]')).toBeTruthy(); + }); + + it('should display no table columns if nothing is configured', () => { + component.requisitions = requisitions; + component.columnsToDisplay = []; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=th-requisition-no]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-order-no]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-creation-date]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-approver]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-buyer]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-approval-date]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-rejection-date]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-line-items]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-order-total]')).toBeFalsy(); + }); + + it('should display table columns if they are configured', () => { + component.requisitions = requisitions; + component.columnsToDisplay = [ + 'requisitionNo', + 'orderNo', + 'creationDate', + 'approver', + 'buyer', + 'approvalDate', + 'rejectionDate', + 'lineItems', + 'orderTotal', + ]; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=th-requisition-no]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-order-no]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-creation-date]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-approver]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-buyer]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-approval-date]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-rejection-date]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-line-items]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=th-order-total]')).toBeTruthy(); + }); +}); diff --git a/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.ts b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.ts new file mode 100644 index 0000000000..d5f60c4910 --- /dev/null +++ b/projects/requisition-management/src/app/components/requisitions-list/requisitions-list.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { Requisition, RequisitionStatus } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-requisitions-list', + templateUrl: './requisitions-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RequisitionsListComponent { + /** + * The requisitions to be listed + */ + @Input() requisitions: Requisition[]; + @Input() status: RequisitionStatus = 'PENDING'; + @Input() columnsToDisplay: string[]; +} diff --git a/projects/requisition-management/src/app/exports/.gitignore b/projects/requisition-management/src/app/exports/.gitignore new file mode 100644 index 0000000000..e2cbbd818a --- /dev/null +++ b/projects/requisition-management/src/app/exports/.gitignore @@ -0,0 +1 @@ +/lazy** diff --git a/projects/requisition-management/src/app/exports/index.ts b/projects/requisition-management/src/app/exports/index.ts new file mode 100644 index 0000000000..1d8a1c7798 --- /dev/null +++ b/projects/requisition-management/src/app/exports/index.ts @@ -0,0 +1,7 @@ +export { RequisitionManagementModule } from '../requisition-management.module'; +export { RequisitionManagementExportsModule } from './requisition-management-exports.module'; + +export { RequisitionManagementBreadcrumbService } from '../services/requisition-management-breadcrumb/requisition-management-breadcrumb.service'; +export { LazyCheckoutReceiptRequisitionComponent } from './lazy-checkout-receipt-requisition/lazy-checkout-receipt-requisition.component'; +export { LazyApprovalWidgetComponent } from './lazy-approval-widget/lazy-approval-widget.component'; +export { LazyRequisitionWidgetComponent } from './lazy-requisition-widget/lazy-requisition-widget.component'; diff --git a/projects/requisition-management/src/app/exports/requisition-management-exports.module.ts b/projects/requisition-management/src/app/exports/requisition-management-exports.module.ts new file mode 100644 index 0000000000..d07f641d19 --- /dev/null +++ b/projects/requisition-management/src/app/exports/requisition-management-exports.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; + +import { LazyApprovalWidgetComponent } from './lazy-approval-widget/lazy-approval-widget.component'; +import { LazyCheckoutReceiptRequisitionComponent } from './lazy-checkout-receipt-requisition/lazy-checkout-receipt-requisition.component'; +import { LazyRequisitionWidgetComponent } from './lazy-requisition-widget/lazy-requisition-widget.component'; + +@NgModule({ + declarations: [LazyApprovalWidgetComponent, LazyCheckoutReceiptRequisitionComponent, LazyRequisitionWidgetComponent], + exports: [LazyApprovalWidgetComponent, LazyCheckoutReceiptRequisitionComponent, LazyRequisitionWidgetComponent], +}) +export class RequisitionManagementExportsModule {} diff --git a/projects/requisition-management/src/app/facades/requisition-context.facade.ts b/projects/requisition-management/src/app/facades/requisition-context.facade.ts new file mode 100644 index 0000000000..7c8ae3ab29 --- /dev/null +++ b/projects/requisition-management/src/app/facades/requisition-context.facade.ts @@ -0,0 +1,73 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { RxState } from '@rx-angular/state'; +import { distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { selectRouteParam, selectUrl } from 'ish-core/store/core/router'; +import { whenTruthy } from 'ish-core/utils/operators'; + +import { Requisition } from '../models/requisition/requisition.model'; +import { + getRequisition, + getRequisitionsError, + getRequisitionsLoading, + loadRequisition, + updateRequisitionStatus, +} from '../store/requisitions'; + +@Injectable() +export class RequisitionContextFacade + extends RxState<{ + id: string; + loading: boolean; + error: HttpError; + entity: Requisition; + view: 'buyer' | 'approver'; + }> + implements OnDestroy { + constructor(private store: Store) { + super(); + + this.connect('id', this.store.pipe(select(selectRouteParam('requisitionId')))); + + this.connect('loading', this.store.pipe(select(getRequisitionsLoading))); + + this.connect('error', this.store.pipe(select(getRequisitionsError))); + + this.connect( + 'entity', + this.select('id').pipe( + whenTruthy(), + distinctUntilChanged(), + tap(requisitionId => this.store.dispatch(loadRequisition({ requisitionId }))), + switchMap(requisitionId => + this.store.pipe( + select(getRequisition(requisitionId)), + whenTruthy(), + map(entity => entity as Requisition) + ) + ), + whenTruthy() + ) + ); + + this.connect( + 'view', + this.store.pipe( + select(selectUrl), + map(url => (url.includes('/buyer') ? 'buyer' : 'approver')) + ) + ); + } + + approveRequisition$() { + this.store.dispatch(updateRequisitionStatus({ requisitionId: this.get('entity', 'id'), status: 'APPROVED' })); + } + + rejectRequisition$(comment?: string) { + this.store.dispatch( + updateRequisitionStatus({ requisitionId: this.get('entity', 'id'), status: 'REJECTED', approvalComment: comment }) + ); + } +} diff --git a/projects/requisition-management/src/app/facades/requisition-management.facade.ts b/projects/requisition-management/src/app/facades/requisition-management.facade.ts new file mode 100644 index 0000000000..6dca25526e --- /dev/null +++ b/projects/requisition-management/src/app/facades/requisition-management.facade.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { Store, select } from '@ngrx/store'; +import { combineLatest } from 'rxjs'; +import { distinctUntilChanged, filter, map, sample, startWith, switchMap } from 'rxjs/operators'; + +import { selectRouteParam, selectUrl } from 'ish-core/store/core/router'; +import { whenTruthy } from 'ish-core/utils/operators'; + +import { RequisitionStatus, RequisitionViewer } from '../models/requisition/requisition.model'; +import { + getRequisition, + getRequisitions, + getRequisitionsError, + getRequisitionsLoading, + loadRequisition, + loadRequisitions, +} from '../store/requisitions'; + +// tslint:disable:member-ordering +@Injectable({ providedIn: 'root' }) +export class RequisitionManagementFacade { + constructor(private store: Store, private router: Router) {} + + requisitionsError$ = this.store.pipe(select(getRequisitionsError)); + requisitionsLoading$ = this.store.pipe(select(getRequisitionsLoading)); + + requisitionsStatus$ = this.store.pipe( + select(selectRouteParam('status')), + map(status => status || 'PENDING') + ); + + selectedRequisition$ = this.store.pipe( + select(selectRouteParam('requisitionId')), + whenTruthy(), + switchMap(requisitionId => this.store.pipe(select(getRequisition(requisitionId)))) + ); + + requisition$(requisitionId: string) { + this.store.dispatch(loadRequisition({ requisitionId })); + return this.store.pipe(select(getRequisition(requisitionId))); + } + + requisitions$(view: RequisitionViewer, status: RequisitionStatus) { + this.store.dispatch(loadRequisitions({ view, status })); + return this.store.pipe(select(getRequisitions(view, status))); + } + + requisitionsByRoute$ = combineLatest([ + this.store.pipe( + select(selectUrl), + map(url => (url.includes('/buyer') ? 'buyer' : 'approver')), + distinctUntilChanged() + ), + this.store.pipe(select(selectRouteParam('status')), distinctUntilChanged()), + ]).pipe( + sample( + this.router.events.pipe( + // only when navigation is finished + filter(e => e instanceof NavigationEnd), + // fire on first subscription + startWith({}) + ) + ), + switchMap(([view, status]) => + this.requisitions$(view as RequisitionViewer, (status as RequisitionStatus) || 'PENDING') + ) + ); +} diff --git a/projects/requisition-management/src/app/models/requisition/requisition.interface.ts b/projects/requisition-management/src/app/models/requisition/requisition.interface.ts new file mode 100644 index 0000000000..f13e50d344 --- /dev/null +++ b/projects/requisition-management/src/app/models/requisition/requisition.interface.ts @@ -0,0 +1,46 @@ +import { AddressData } from 'ish-core/models/address/address.interface'; +import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; +import { BasketRebateData } from 'ish-core/models/basket-rebate/basket-rebate.interface'; +import { BasketBaseData } from 'ish-core/models/basket/basket.interface'; +import { LineItemData } from 'ish-core/models/line-item/line-item.interface'; +import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; +import { PaymentMethodBaseData } from 'ish-core/models/payment-method/payment-method.interface'; +import { PaymentData } from 'ish-core/models/payment/payment.interface'; +import { PriceData } from 'ish-core/models/price/price.interface'; +import { ShippingMethodData } from 'ish-core/models/shipping-method/shipping-method.interface'; +import { User } from 'ish-core/models/user/user.model'; + +import { RequisitionApproval, RequisitionUserBudget } from './requisition.model'; + +export interface RequisitionBaseData extends BasketBaseData { + requisitionNo: string; + orderNo?: string; + order?: { + itemId: string; + }; + creationDate: number; + lineItemCount: number; + totalGross: PriceData; + totalNet: PriceData; + + userInformation: User; + userBudgets: RequisitionUserBudget; + + approvalStatus: RequisitionApproval; +} + +export interface RequisitionData { + data: RequisitionBaseData | RequisitionBaseData[]; + included?: { + invoiceToAddress?: { [urn: string]: AddressData }; + lineItems?: { [id: string]: LineItemData }; + discounts?: { [id: string]: BasketRebateData }; + lineItems_discounts?: { [id: string]: BasketRebateData }; + commonShipToAddress?: { [urn: string]: AddressData }; + commonShippingMethod?: { [id: string]: ShippingMethodData }; + payments?: { [id: string]: PaymentData }; + payments_paymentMethod?: { [id: string]: PaymentMethodBaseData }; + payments_paymentInstrument?: { [id: string]: PaymentInstrument }; + }; + infos?: BasketInfo[]; +} 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 new file mode 100644 index 0000000000..f2fcc1a981 --- /dev/null +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; + +import { RequisitionBaseData } from './requisition.interface'; +import { RequisitionMapper } from './requisition.mapper'; + +describe('Requisition Mapper', () => { + let requisitionMapper: RequisitionMapper; + + beforeEach(() => { + TestBed.configureTestingModule({}); + requisitionMapper = TestBed.inject(RequisitionMapper); + }); + + describe('fromData', () => { + it('should throw when input is falsy', () => { + expect(() => requisitionMapper.fromData(undefined)).toThrow(); + }); + + it('should map incoming data to model data', () => { + const data = { + id: 'testUUDI', + requisitionNo: '0001', + orderNo: '10001', + invoiceToAddress: 'urn_invoiceToAddress_123', + commonShipToAddress: 'urn_commonShipToAddress_123', + commonShippingMethod: 'shipping_method_123', + customer: 'OilCorp', + user: 'bboldner@test.intershop.de', + creationDate: 12345678, + lineItemCount: 2, + approvalStatus: { + status: 'APPROVED', + approver: { firstName: 'Bernhard', lastName: 'Boldner' }, + approvalDate: 76543627, + }, + userInformation: { firstName: 'Patricia', lastName: 'Miller', email: 'pmiller@test.intershop.de' }, + userBudgets: { + budgetPeriod: 'weekly', + orderSpentLimit: { currency: 'USD', value: 500, type: 'Money' }, + budget: { currency: 'USD', value: 3000, type: 'Money' }, + }, + totals: {}, + totalGross: { currency: 'USD', value: 2000 }, + totalNet: { currency: 'USD', value: 1890 }, + } as RequisitionBaseData; + + const mapped = requisitionMapper.fromData({ data }); + expect(mapped).toMatchInlineSnapshot(` + Object { + "approval": Object { + "approvalDate": 76543627, + "approver": Object { + "firstName": "Bernhard", + "lastName": "Boldner", + }, + "customerApprovers": undefined, + "status": "APPROVED", + }, + "bucketId": undefined, + "commonShipToAddress": undefined, + "commonShippingMethod": undefined, + "creationDate": 12345678, + "customerNo": "OilCorp", + "dynamicMessages": undefined, + "email": "bboldner@test.intershop.de", + "id": "testUUDI", + "infos": undefined, + "invoiceToAddress": undefined, + "lineItemCount": 2, + "lineItems": Array [], + "orderNo": "10001", + "payment": undefined, + "promotionCodes": undefined, + "purchaseCurrency": undefined, + "requisitionNo": "0001", + "totalProductQuantity": undefined, + "totals": Object { + "bucketSurchargeTotalsByType": undefined, + "dutiesAndSurchargesTotal": undefined, + "isEstimated": false, + "itemRebatesTotal": undefined, + "itemShippingRebatesTotal": undefined, + "itemSurchargeTotalsByType": undefined, + "itemTotal": undefined, + "paymentCostsTotal": undefined, + "shippingRebates": undefined, + "shippingRebatesTotal": undefined, + "shippingTotal": undefined, + "taxTotal": undefined, + "total": undefined, + "undiscountedItemTotal": undefined, + "undiscountedShippingTotal": undefined, + "valueRebates": undefined, + "valueRebatesTotal": undefined, + }, + "user": Object { + "email": "pmiller@test.intershop.de", + "firstName": "Patricia", + "lastName": "Miller", + }, + "userBudget": Object { + "budget": Object { + "currency": "USD", + "type": "Money", + "value": 3000, + }, + "budgetPeriod": "weekly", + "orderSpentLimit": Object { + "currency": "USD", + "type": "Money", + "value": 500, + }, + "spentBudget": Object { + "currency": "USD", + "type": "Money", + "value": 0, + }, + }, + } + `); + }); + }); +}); diff --git a/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts b/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts new file mode 100644 index 0000000000..66a01fac7d --- /dev/null +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; + +import { BasketData } from 'ish-core/models/basket/basket.interface'; +import { BasketMapper } from 'ish-core/models/basket/basket.mapper'; +import { OrderData } from 'ish-core/models/order/order.interface'; +import { PriceItemMapper } from 'ish-core/models/price-item/price-item.mapper'; +import { Price } from 'ish-core/models/price/price.model'; + +import { RequisitionData } from './requisition.interface'; +import { Requisition } from './requisition.model'; + +@Injectable({ providedIn: 'root' }) +export class RequisitionMapper { + fromData(payload: RequisitionData, orderPayload?: OrderData): Requisition { + if (!Array.isArray(payload.data)) { + const { data } = payload; + const emptyPrice: Price = { + type: 'Money', + value: 0, + currency: data.userBudgets?.budget?.currency, + }; + + if (data) { + const payloadData = (orderPayload ? orderPayload : payload) as BasketData; + payloadData.data.calculated = true; + + return { + ...BasketMapper.fromData(payloadData), + id: data.id, + requisitionNo: data.requisitionNo, + orderNo: data.orderNo, + creationDate: data.creationDate, + userBudget: { ...data.userBudgets, spentBudget: data.userBudgets?.spentBudget || emptyPrice }, + lineItemCount: data.lineItemCount, + user: data.userInformation, + approval: { + ...data.approvalStatus, + customerApprovers: data.approval?.customerApproval?.approvers, + }, + }; + } else { + throw new Error(`requisitionData is required`); + } + } + } + + fromListData(payload: RequisitionData): Requisition[] { + if (Array.isArray(payload.data)) { + return ( + payload.data + /* filter requisitions that didn't need an approval */ + .filter(data => data.requisitionNo) + .map(data => ({ + ...this.fromData({ ...payload, data }), + totals: { + itemTotal: data.totals ? PriceItemMapper.fromPriceItem(data.totals.itemTotal) : undefined, + total: data.totals + ? PriceItemMapper.fromPriceItem(data.totals.grandTotal) + : { + type: 'PriceItem', + gross: data.totalGross.value, + net: data.totalNet.value, + currency: data.totalGross.currency, + }, + isEstimated: false, + }, + })) + ); + } + } +} diff --git a/projects/requisition-management/src/app/models/requisition/requisition.model.ts b/projects/requisition-management/src/app/models/requisition/requisition.model.ts new file mode 100644 index 0000000000..dc2af366db --- /dev/null +++ b/projects/requisition-management/src/app/models/requisition/requisition.model.ts @@ -0,0 +1,37 @@ +import { UserBudget } from 'organization-management'; + +import { AbstractBasket } from 'ish-core/models/basket/basket.model'; +import { LineItem } from 'ish-core/models/line-item/line-item.model'; +import { Price } from 'ish-core/models/price/price.model'; +import { User } from 'ish-core/models/user/user.model'; + +export type RequisitionStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +export type RequisitionViewer = 'buyer' | 'approver'; + +export interface RequisitionApproval { + status: string; + statusCode: string; + approvalDate?: number; + approver?: { firstName: string; lastName: string }; + approvalComment?: string; + customerApprovers?: { firstName: string; lastName: string; email: string }[]; +} + +export interface RequisitionUserBudget extends UserBudget { + spentBudgetIncludingThisRequisition?: Price; + remainingBudgetIncludingThisRequisition?: Price; +} + +type RequisitionBasket = Omit, 'approval'>; + +export interface Requisition extends RequisitionBasket { + requisitionNo: string; + orderNo?: string; + creationDate: number; + lineItemCount: number; + + user: User; + userBudget: RequisitionUserBudget; + approval: RequisitionApproval; +} diff --git a/projects/requisition-management/src/app/pages/approver/approver-page.component.html b/projects/requisition-management/src/app/pages/approver/approver-page.component.html new file mode 100644 index 0000000000..a703064323 --- /dev/null +++ b/projects/requisition-management/src/app/pages/approver/approver-page.component.html @@ -0,0 +1,33 @@ +

{{ 'account.requisitions.approvals' | translate }}

+ +

{{ 'account.requisitions.approvals.text' | translate }}

+ + + +
+ + + + + +
diff --git a/projects/requisition-management/src/app/pages/approver/approver-page.component.spec.ts b/projects/requisition-management/src/app/pages/approver/approver-page.component.spec.ts new file mode 100644 index 0000000000..4a9e2cd2d5 --- /dev/null +++ b/projects/requisition-management/src/app/pages/approver/approver-page.component.spec.ts @@ -0,0 +1,73 @@ +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 { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { RequisitionsListComponent } from '../../components/requisitions-list/requisitions-list.component'; +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; + +import { ApproverPageComponent } from './approver-page.component'; + +describe('Approver Page Component', () => { + let component: ApproverPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let reqFacade: RequisitionManagementFacade; + + beforeEach(async () => { + reqFacade = mock(RequisitionManagementFacade); + await TestBed.configureTestingModule({ + imports: [NgbNavModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [ + ApproverPageComponent, + MockComponent(ErrorMessageComponent), + MockComponent(LoadingComponent), + MockComponent(RequisitionsListComponent), + ], + providers: [{ provide: RequisitionManagementFacade, useFactory: () => instance(reqFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ApproverPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(reqFacade.requisitionsStatus$).thenReturn(of('PENDING')); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display loading overlay if requisitions are loading', () => { + when(reqFacade.requisitionsLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); + + it('should display pending tab as active if status is PENDING', () => { + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-pending]').getAttribute('class')).toContain('active'); + }); + + it('should display approved tab as active if status is APPROVED', () => { + when(reqFacade.requisitionsStatus$).thenReturn(of('APPROVED')); + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-approved]').getAttribute('class')).toContain('active'); + }); + + it('should display rejected tab as active if status is REJECTED', () => { + when(reqFacade.requisitionsStatus$).thenReturn(of('REJECTED')); + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-rejected]').getAttribute('class')).toContain('active'); + }); +}); diff --git a/projects/requisition-management/src/app/pages/approver/approver-page.component.ts b/projects/requisition-management/src/app/pages/approver/approver-page.component.ts new file mode 100644 index 0000000000..a90a76b16d --- /dev/null +++ b/projects/requisition-management/src/app/pages/approver/approver-page.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition, RequisitionStatus } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-approver-page', + templateUrl: './approver-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApproverPageComponent implements OnInit, OnDestroy { + requisitions$: Observable; + error$: Observable; + loading$: Observable; + status$: Observable; + + constructor(private requisitionManagementFacade: RequisitionManagementFacade) {} + + status: RequisitionStatus; + columnsToDisplay: string[]; + private destroy$ = new Subject(); + + ngOnInit() { + this.requisitions$ = this.requisitionManagementFacade.requisitionsByRoute$; + this.error$ = this.requisitionManagementFacade.requisitionsError$; + this.loading$ = this.requisitionManagementFacade.requisitionsLoading$; + this.status$ = this.requisitionManagementFacade.requisitionsStatus$ as Observable; + + this.status$.pipe(takeUntil(this.destroy$)).subscribe(status => { + this.status = status; + switch (status) { + case 'APPROVED': + this.columnsToDisplay = [ + 'requisitionNo', + 'orderNoSimple', + 'creationDate', + 'buyer', + 'approvalDate', + 'orderTotal', + ]; + break; + case 'REJECTED': + this.columnsToDisplay = [ + 'requisitionNo', + 'creationDate', + 'buyer', + 'rejectionDate', + 'lineItems', + 'orderTotal', + ]; + break; + default: + this.columnsToDisplay = ['requisitionNo', 'creationDate', 'buyer', 'lineItems', 'orderTotal']; + break; + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/requisition-management/src/app/pages/buyer/buyer-page.component.html b/projects/requisition-management/src/app/pages/buyer/buyer-page.component.html new file mode 100644 index 0000000000..9244627c67 --- /dev/null +++ b/projects/requisition-management/src/app/pages/buyer/buyer-page.component.html @@ -0,0 +1,33 @@ +

{{ 'account.requisitions.requisitions' | translate }}

+ +

{{ 'account.requisitions.requisitions.text' | translate }}

+ + + +
+ + + + + +
diff --git a/projects/requisition-management/src/app/pages/buyer/buyer-page.component.spec.ts b/projects/requisition-management/src/app/pages/buyer/buyer-page.component.spec.ts new file mode 100644 index 0000000000..47ae42bb1b --- /dev/null +++ b/projects/requisition-management/src/app/pages/buyer/buyer-page.component.spec.ts @@ -0,0 +1,72 @@ +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 { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { RequisitionsListComponent } from '../../components/requisitions-list/requisitions-list.component'; +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; + +import { BuyerPageComponent } from './buyer-page.component'; + +describe('Buyer Page Component', () => { + let component: BuyerPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let reqFacade: RequisitionManagementFacade; + + beforeEach(async () => { + reqFacade = mock(RequisitionManagementFacade); + await TestBed.configureTestingModule({ + imports: [NgbNavModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [ + BuyerPageComponent, + MockComponent(ErrorMessageComponent), + MockComponent(LoadingComponent), + MockComponent(RequisitionsListComponent), + ], + providers: [{ provide: RequisitionManagementFacade, useFactory: () => instance(reqFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BuyerPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + when(reqFacade.requisitionsStatus$).thenReturn(of('PENDING')); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display loading overlay if requisitions are loading', () => { + when(reqFacade.requisitionsLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); + + it('should display pending tab as active if status is PENDING', () => { + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-pending]').getAttribute('class')).toContain('active'); + }); + + it('should display approved tab as active if status is APPROVED', () => { + when(reqFacade.requisitionsStatus$).thenReturn(of('APPROVED')); + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-approved]').getAttribute('class')).toContain('active'); + }); + + it('should display rejected tab as active if status is REJECTED', () => { + when(reqFacade.requisitionsStatus$).thenReturn(of('REJECTED')); + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-rejected]').getAttribute('class')).toContain('active'); + }); +}); diff --git a/projects/requisition-management/src/app/pages/buyer/buyer-page.component.ts b/projects/requisition-management/src/app/pages/buyer/buyer-page.component.ts new file mode 100644 index 0000000000..f81c247a3c --- /dev/null +++ b/projects/requisition-management/src/app/pages/buyer/buyer-page.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition, RequisitionStatus } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-buyer-page', + templateUrl: './buyer-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BuyerPageComponent implements OnInit, OnDestroy { + requisitions$: Observable; + error$: Observable; + loading$: Observable; + status$: Observable; + + status: RequisitionStatus; + columnsToDisplay: string[]; + private destroy$ = new Subject(); + + constructor(private requisitionManagementFacade: RequisitionManagementFacade) {} + + ngOnInit() { + this.requisitions$ = this.requisitionManagementFacade.requisitionsByRoute$; + this.error$ = this.requisitionManagementFacade.requisitionsError$; + this.loading$ = this.requisitionManagementFacade.requisitionsLoading$; + this.status$ = this.requisitionManagementFacade.requisitionsStatus$ as Observable; + + this.status$.pipe(takeUntil(this.destroy$)).subscribe(status => { + this.status = status; + switch (status) { + case 'APPROVED': + this.columnsToDisplay = [ + 'requisitionNo', + 'orderNo', + 'creationDate', + 'approver', + 'approvalDate', + 'orderTotal', + ]; + break; + case 'REJECTED': + this.columnsToDisplay = [ + 'requisitionNo', + 'creationDate', + 'approver', + 'rejectionDate', + 'lineItems', + 'orderTotal', + ]; + break; + default: + this.columnsToDisplay = ['requisitionNo', 'creationDate', 'lineItems', 'orderTotal']; + break; + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html new file mode 100644 index 0000000000..4729c0c1e7 --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html @@ -0,0 +1,111 @@ +
+ + +

+ {{ + 'approval.detailspage.approval.heading' | translate + }} + {{ 'approval.detailspage.requisition.heading' | translate }} +

+ + +
+ + + + + + +

{{ 'approval.detailspage.order_details.heading' | translate }}

+ +
+ + + + + + + +

+ {{ requisition.payment?.displayName }}
{{ requisition.payment?.paymentInstrument?.accountIdentifier }} + + {{ requisition.payment?.paymentInstrument }} + +

+
+
+ +
+ + + + + + + +

{{ requisition.commonShippingMethod?.name }}

+
+
+ + + + + +
+
+
+

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

+ +
+
+
+ + + + +
+ + +
+
+ + + + + +
+
diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts new file mode 100644 index 0000000000..bd0dae82ba --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts @@ -0,0 +1,88 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +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 { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; +import { LineItemListComponent } from 'ish-shared/components/line-item/line-item-list/line-item-list.component'; + +import { RequisitionBuyerApprovalComponent } from '../../components/requisition-buyer-approval/requisition-buyer-approval.component'; +import { RequisitionRejectDialogComponent } from '../../components/requisition-reject-dialog/requisition-reject-dialog.component'; +import { RequisitionSummaryComponent } from '../../components/requisition-summary/requisition-summary.component'; +import { RequisitionContextFacade } from '../../facades/requisition-context.facade'; + +import { RequisitionDetailPageComponent } from './requisition-detail-page.component'; + +describe('Requisition Detail Page Component', () => { + let component: RequisitionDetailPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let context: RequisitionContextFacade; + + beforeEach(async () => { + context = mock(RequisitionContextFacade); + + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, TranslateModule.forRoot()], + declarations: [ + MockComponent(AddressComponent), + MockComponent(BasketCostSummaryComponent), + MockComponent(ErrorMessageComponent), + MockComponent(FaIconComponent), + MockComponent(InfoBoxComponent), + MockComponent(LineItemListComponent), + MockComponent(LoadingComponent), + MockComponent(RequisitionBuyerApprovalComponent), + MockComponent(RequisitionRejectDialogComponent), + MockComponent(RequisitionSummaryComponent), + RequisitionDetailPageComponent, + ], + }) + .overrideComponent(RequisitionDetailPageComponent, { + set: { providers: [{ provide: RequisitionContextFacade, useFactory: () => instance(context) }] }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RequisitionDetailPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(context.select('entity')).thenReturn(of()); + when(context.select('view')).thenReturn(of('approver')); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display the requisition title for default', () => { + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` +
+ +

approval.detailspage.approval.heading

+ +
+ `); + }); +}); diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.ts b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.ts new file mode 100644 index 0000000000..4227e560f6 --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { RequisitionContextFacade } from '../../facades/requisition-context.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; + +@Component({ + selector: 'ish-requisition-detail-page', + templateUrl: './requisition-detail-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [RequisitionContextFacade], +}) +export class RequisitionDetailPageComponent implements OnInit { + requisition$: Observable; + error$: Observable; + loading$: Observable; + view$: Observable<'buyer' | 'approver'>; + + constructor(private context: RequisitionContextFacade) {} + + ngOnInit() { + this.requisition$ = this.context.select('entity'); + this.loading$ = this.context.select('loading'); + this.error$ = this.context.select('error'); + this.view$ = this.context.select('view'); + } + + approveRequisition() { + this.context.approveRequisition$(); + } + + rejectRequisition(comment: string) { + this.context.rejectRequisition$(comment); + return false; + } +} diff --git a/projects/requisition-management/src/app/pages/requisition-management-routing.module.ts b/projects/requisition-management/src/app/pages/requisition-management-routing.module.ts new file mode 100644 index 0000000000..15a46061d1 --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-management-routing.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { ApproverPageComponent } from './approver/approver-page.component'; +import { BuyerPageComponent } from './buyer/buyer-page.component'; +import { RequisitionDetailPageComponent } from './requisition-detail/requisition-detail-page.component'; + +/** + * routes for the requisition management + * + * visible for testing + */ +export const routes: Routes = [ + { path: 'approver', component: ApproverPageComponent }, + { path: 'buyer', component: BuyerPageComponent }, + { path: 'approver/:requisitionId', component: RequisitionDetailPageComponent }, + { path: 'buyer/:requisitionId', component: RequisitionDetailPageComponent }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class RequisitionManagementRoutingModule {} diff --git a/projects/requisition-management/src/app/requisition-management.module.ts b/projects/requisition-management/src/app/requisition-management.module.ts new file mode 100644 index 0000000000..d3c10df278 --- /dev/null +++ b/projects/requisition-management/src/app/requisition-management.module.ts @@ -0,0 +1,43 @@ +import { CdkTableModule } from '@angular/cdk/table'; +import { NgModule } from '@angular/core'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { ApprovalWidgetComponent } from './components/approval-widget/approval-widget.component'; +import { BudgetBarComponent } from './components/budget-bar/budget-bar.component'; +import { CheckoutReceiptRequisitionComponent } from './components/checkout-receipt-requisition/checkout-receipt-requisition.component'; +import { RequisitionBuyerApprovalComponent } from './components/requisition-buyer-approval/requisition-buyer-approval.component'; +import { RequisitionRejectDialogComponent } from './components/requisition-reject-dialog/requisition-reject-dialog.component'; +import { RequisitionSummaryComponent } from './components/requisition-summary/requisition-summary.component'; +import { RequisitionWidgetComponent } from './components/requisition-widget/requisition-widget.component'; +import { RequisitionsListComponent } from './components/requisitions-list/requisitions-list.component'; +import { ApproverPageComponent } from './pages/approver/approver-page.component'; +import { BuyerPageComponent } from './pages/buyer/buyer-page.component'; +import { RequisitionDetailPageComponent } from './pages/requisition-detail/requisition-detail-page.component'; +import { RequisitionManagementRoutingModule } from './pages/requisition-management-routing.module'; +import { RequisitionManagementStoreModule } from './store/requisition-management-store.module'; + +@NgModule({ + declarations: [ + ApprovalWidgetComponent, + ApproverPageComponent, + BudgetBarComponent, + BuyerPageComponent, + CheckoutReceiptRequisitionComponent, + RequisitionBuyerApprovalComponent, + RequisitionDetailPageComponent, + RequisitionRejectDialogComponent, + RequisitionSummaryComponent, + RequisitionWidgetComponent, + RequisitionsListComponent, + ], + imports: [ + CdkTableModule, + NgbNavModule, + RequisitionManagementRoutingModule, + RequisitionManagementStoreModule, + SharedModule, + ], +}) +export class RequisitionManagementModule {} diff --git a/projects/requisition-management/src/app/services/requisition-management-breadcrumb/requisition-management-breadcrumb.service.spec.ts b/projects/requisition-management/src/app/services/requisition-management-breadcrumb/requisition-management-breadcrumb.service.spec.ts new file mode 100644 index 0000000000..b68a754e6a --- /dev/null +++ b/projects/requisition-management/src/app/services/requisition-management-breadcrumb/requisition-management-breadcrumb.service.spec.ts @@ -0,0 +1,144 @@ +import { Component, Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Route, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; +import { Requisition } from '../../models/requisition/requisition.model'; +import { routes } from '../../pages/requisition-management-routing.module'; + +import { RequisitionManagementBreadcrumbService } from './requisition-management-breadcrumb.service'; + +// tslint:disable-next-line: no-any +function adaptRoutes(rts: Route[], cmp: Type): Route[] { + return rts.map(r => ({ + ...r, + path: 'requisitions/' + r.path, + component: r.component && cmp, + })); +} + +describe('Requisition Management Breadcrumb Service', () => { + let requisitionManagementBreadcrumbService: RequisitionManagementBreadcrumbService; + let router: Router; + let reqFacade: RequisitionManagementFacade; + + beforeEach(() => { + @Component({ template: 'dummy' }) + class DummyComponent {} + reqFacade = mock(RequisitionManagementFacade); + TestBed.configureTestingModule({ + declarations: [DummyComponent], + imports: [ + CoreStoreModule.forTesting(['router', 'configuration']), + RouterTestingModule.withRoutes([ + ...adaptRoutes(routes, DummyComponent), + { path: '**', component: DummyComponent }, + ]), + TranslateModule.forRoot(), + ], + providers: [{ provide: RequisitionManagementFacade, useFactory: () => instance(reqFacade) }], + }); + requisitionManagementBreadcrumbService = TestBed.inject(RequisitionManagementBreadcrumbService); + router = TestBed.inject(Router); + + when(reqFacade.selectedRequisition$).thenReturn(of({ id: '65435435', requisitionNo: '12345' } as Requisition)); + + router.initialNavigation(); + }); + + it('should be created', () => { + expect(requisitionManagementBreadcrumbService).toBeTruthy(); + }); + + describe('breadcrumb$', () => { + describe('unrelated routes', () => { + it('should not report a breadcrumb for unrelated routes', done => { + router.navigateByUrl('/foobar'); + requisitionManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(fail, fail, fail); + setTimeout(done, 2000); + }); + }); + + describe('requisition management routes', () => { + it('should set breadcrumb for requisitions buyer list view', done => { + router.navigateByUrl('/requisitions/buyer'); + requisitionManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.requisitions.requisitions", + }, + ] + `); + done(); + }); + }); + + it('should set breadcrumb for requisitions approver list view', done => { + router.navigateByUrl('/requisitions/approver'); + requisitionManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.requisitions.approvals", + }, + ] + `); + done(); + }); + }); + }); + + it('should set breadcrumb for requisitions buyer detail view', done => { + router.navigateByUrl('/requisitions/buyer/12345;status=pending'); + requisitionManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.requisitions.requisitions", + "link": Array [ + "/my-account/buyer", + Object { + "status": "pending", + }, + ], + }, + Object { + "text": "approval.details.breadcrumb.order.label - 12345", + }, + ] + `); + done(); + }); + }); + + it('should set breadcrumb for requisitions buyer detail view', done => { + router.navigateByUrl('/requisitions/approver/12345;status=rejected'); + requisitionManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.requisitions.approvals", + "link": Array [ + "/my-account/approver", + Object { + "status": "rejected", + }, + ], + }, + Object { + "text": "approval.details.breadcrumb.order.label - 12345", + }, + ] + `); + done(); + }); + }); + }); +}); diff --git a/projects/requisition-management/src/app/services/requisition-management-breadcrumb/requisition-management-breadcrumb.service.ts b/projects/requisition-management/src/app/services/requisition-management-breadcrumb/requisition-management-breadcrumb.service.ts new file mode 100644 index 0000000000..f3f1e906ac --- /dev/null +++ b/projects/requisition-management/src/app/services/requisition-management-breadcrumb/requisition-management-breadcrumb.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { EMPTY, Observable, of } from 'rxjs'; +import { map, switchMap, withLatestFrom } from 'rxjs/operators'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { BreadcrumbItem } from 'ish-core/models/breadcrumb-item/breadcrumb-item.interface'; +import { selectRouteParam } from 'ish-core/store/core/router'; +import { whenFalsy, whenTruthy } from 'ish-core/utils/operators'; + +import { RequisitionManagementFacade } from '../../facades/requisition-management.facade'; + +@Injectable({ providedIn: 'root' }) +export class RequisitionManagementBreadcrumbService { + constructor( + private appFacade: AppFacade, + private store: Store, + private requisitionManagementFacade: RequisitionManagementFacade, + private translateService: TranslateService + ) {} + + breadcrumb$(prefix: string): Observable { + return this.appFacade.routingInProgress$.pipe( + whenFalsy(), + withLatestFrom(this.appFacade.path$.pipe(whenTruthy())), + switchMap(([, path]) => { + if (path.endsWith('/buyer')) { + return of([{ key: 'account.requisitions.requisitions' }]); + } + if (path.endsWith('/approver')) { + return of([{ key: 'account.requisitions.approvals' }]); + } + if (path.includes('/approver/') || path.includes('/buyer/')) { + return this.requisitionManagementFacade.selectedRequisition$.pipe( + whenTruthy(), + withLatestFrom( + this.translateService.get('approval.details.breadcrumb.order.label'), + this.store.pipe(select(selectRouteParam('status'))) + ), + map(([req, translation, status]) => + path.includes('/approver/') + ? [ + { + key: 'account.requisitions.approvals', + link: [prefix + '/approver', { status }], + }, + { + text: `${translation} - ${req.requisitionNo}`, + }, + ] + : [ + { + key: 'account.requisitions.requisitions', + link: [prefix + '/buyer', { status }], + }, + { + text: `${translation} - ${req.requisitionNo}`, + }, + ] + ) + ); + } + return EMPTY; + }) + ); + } +} diff --git a/projects/requisition-management/src/app/services/requisitions/requisitions.service.spec.ts b/projects/requisition-management/src/app/services/requisitions/requisitions.service.spec.ts new file mode 100644 index 0000000000..8295467001 --- /dev/null +++ b/projects/requisition-management/src/app/services/requisitions/requisitions.service.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { ApiService } from 'ish-core/services/api/api.service'; + +import { RequisitionsService } from './requisitions.service'; + +describe('Requisitions Service', () => { + let apiServiceMock: ApiService; + let requisitionsService: RequisitionsService; + + beforeEach(() => { + apiServiceMock = mock(ApiService); + when(apiServiceMock.b2bUserEndpoint()).thenReturn(instance(apiServiceMock)); + TestBed.configureTestingModule({ + providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }], + }); + requisitionsService = TestBed.inject(RequisitionsService); + + when(apiServiceMock.get(anything(), anything())).thenReturn(of({ data: {} })); + when(apiServiceMock.patch(anything(), anything(), anything())).thenReturn(of({ data: {} })); + }); + + it('should be created', () => { + expect(requisitionsService).toBeTruthy(); + }); + + it('should call the getRequisitions of customer API when fetching requisitions', done => { + requisitionsService.getRequisitions('buyer', 'PENDING').subscribe(() => { + verify(apiServiceMock.get('requisitions', anything())).once(); + done(); + }); + }); + + it('should call getRequisition of customer API when fetching a requisition', done => { + requisitionsService.getRequisition('4712').subscribe(() => { + verify(apiServiceMock.get('requisitions/4712', anything())).once(); + done(); + }); + }); + + it('should call updateRequisitionStatus of customer API when patching a requisition status', done => { + requisitionsService.updateRequisitionStatus('4712', 'APPROVED').subscribe(() => { + verify(apiServiceMock.patch('requisitions/4712', anything(), anything())).once(); + done(); + }); + }); +}); diff --git a/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts b/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts new file mode 100644 index 0000000000..33ea0fec37 --- /dev/null +++ b/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts @@ -0,0 +1,144 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { concatMap, map } from 'rxjs/operators'; + +import { OrderData } from 'ish-core/models/order/order.interface'; +import { ApiService } from 'ish-core/services/api/api.service'; + +import { RequisitionData } from '../../models/requisition/requisition.interface'; +import { RequisitionMapper } from '../../models/requisition/requisition.mapper'; +import { Requisition, RequisitionStatus, RequisitionViewer } from '../../models/requisition/requisition.model'; + +type RequisitionIncludeType = + | 'invoiceToAddress' + | 'commonShipToAddress' + | 'commonShippingMethod' + | 'discounts' + | 'lineItems_discounts' + | 'lineItems' + | 'payments' + | 'payments_paymentMethod' + | 'payments_paymentInstrument'; + +@Injectable({ providedIn: 'root' }) +export class RequisitionsService { + constructor(private apiService: ApiService, private requisitionMapper: RequisitionMapper) {} + + private allIncludes: RequisitionIncludeType[] = [ + 'invoiceToAddress', + 'commonShipToAddress', + 'commonShippingMethod', + 'discounts', + 'lineItems_discounts', + 'lineItems', + 'payments', + 'payments_paymentMethod', + 'payments_paymentInstrument', + ]; + + private orderHeaders = new HttpHeaders({ + 'content-type': 'application/json', + Accept: 'application/vnd.intershop.order.v1+json', + }); + + /** + * Get all customer requisitions of a certain status and view. The current user is expected to have the approver permission. + * @param view Defines whether the 'buyer' or 'approver' view is returned. Default: 'buyer' + * @param status Approval status filter. Default: All requisitions are returned + * @returns Requisitions of the customer with their main attributes. To get all properties the getRequisition call is needed. + */ + getRequisitions(view?: RequisitionViewer, status?: RequisitionStatus): Observable { + let params = new HttpParams(); + if (view) { + params = params.set('view', view); + } + if (status) { + params = params.set('status', status); + } + + return this.apiService + .b2bUserEndpoint() + .get(`requisitions`, { params }) + .pipe(map(data => this.requisitionMapper.fromListData(data))); + } + + /** + * Get a customer requisition of a certain id. The current user is expected to have the approver permission. + * @param id Requisition id. + * @returns Requisition with all attributes. If the requisition is approved and the order is placed, also order data are returned as part of the requisition. + */ + getRequisition(requisitionId: string): Observable { + if (!requisitionId) { + return throwError('getRequisition() called without required id'); + } + + const params = new HttpParams().set('include', this.allIncludes.join()); + + return this.apiService + .b2bUserEndpoint() + .get(`requisitions/${requisitionId}`, { + params, + }) + .pipe(concatMap(payload => this.processRequisitionData(payload))); + } + + /** + * Updates the requisition status. The current user is expected to have the approver permission. + * @param id Requisition id. + * @param statusCode The requisition approval status + * @param comment The approval comment + * @returns The updated requisition with all attributes. If the requisition is approved and the order is placed, also order data are returned as part of the requisition. + */ + updateRequisitionStatus( + requisitionId: string, + statusCode: RequisitionStatus, + approvalComment?: string + ): Observable { + if (!requisitionId) { + return throwError('updateRequisitionStatus() called without required id'); + } + if (!statusCode) { + return throwError('updateRequisitionStatus() called without required requisition status'); + } + + const params = new HttpParams().set('include', this.allIncludes.join()); + const body = { + name: 'string', + type: 'ApprovalStatusChange', + statusCode, + approvalComment, + }; + + return this.apiService + .b2bUserEndpoint() + .patch(`requisitions/${requisitionId}`, body, { + params, + }) + .pipe(concatMap(payload => this.processRequisitionData(payload))); + } + + /** + * Gets the order data, if needed and maps the requisition/order data. + * @param payload The requisition row data returnedby the REST interface. + * @returns The requisition. + */ + private processRequisitionData(payload: RequisitionData): Observable { + const params = new HttpParams().set('include', this.allIncludes.join()); + + if (!Array.isArray(payload.data)) { + const requisitionData = payload.data; + + if (requisitionData.order?.itemId) { + return this.apiService + .get(`orders/${requisitionData.order.itemId}`, { + headers: this.orderHeaders, + params, + }) + .pipe(map(data => this.requisitionMapper.fromData(payload, data))); + } + } + + return of(this.requisitionMapper.fromData(payload)); + } +} diff --git a/projects/requisition-management/src/app/store/requisition-management-store.module.ts b/projects/requisition-management/src/app/store/requisition-management-store.module.ts new file mode 100644 index 0000000000..009802ea33 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisition-management-store.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { ActionReducerMap, StoreModule } from '@ngrx/store'; +import { pick } from 'lodash-es'; + +import { resetOnLogoutMeta } from 'ish-core/utils/meta-reducers'; + +import { RequisitionManagementState } from './requisition-management-store'; +import { RequisitionsEffects } from './requisitions/requisitions.effects'; +import { requisitionsReducer } from './requisitions/requisitions.reducer'; + +const requisitionManagementReducers: ActionReducerMap = { + requisitions: requisitionsReducer, +}; + +const requisitionManagementEffects = [RequisitionsEffects]; + +const metaReducers = [resetOnLogoutMeta]; + +@NgModule({ + imports: [ + EffectsModule.forFeature(requisitionManagementEffects), + StoreModule.forFeature('requisitionManagement', requisitionManagementReducers, { metaReducers }), + ], +}) +export class RequisitionManagementStoreModule { + static forTesting(...reducers: (keyof ActionReducerMap)[]) { + return StoreModule.forFeature('requisitionManagement', pick(requisitionManagementReducers, reducers), { + metaReducers, + }); + } +} diff --git a/projects/requisition-management/src/app/store/requisition-management-store.ts b/projects/requisition-management/src/app/store/requisition-management-store.ts new file mode 100644 index 0000000000..5844a2cb25 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisition-management-store.ts @@ -0,0 +1,9 @@ +import { createFeatureSelector } from '@ngrx/store'; + +import { RequisitionsState } from './requisitions/requisitions.reducer'; + +export interface RequisitionManagementState { + requisitions: RequisitionsState; +} + +export const getRequisitionManagementState = createFeatureSelector('requisitionManagement'); diff --git a/projects/requisition-management/src/app/store/requisitions/index.ts b/projects/requisition-management/src/app/store/requisitions/index.ts new file mode 100644 index 0000000000..ef5465cd1d --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/index.ts @@ -0,0 +1,4 @@ +// tslint:disable no-barrel-files +// API to access ngrx requisitions state +export * from './requisitions.actions'; +export * from './requisitions.selectors'; diff --git a/projects/requisition-management/src/app/store/requisitions/requisitions.actions.ts b/projects/requisition-management/src/app/store/requisitions/requisitions.actions.ts new file mode 100644 index 0000000000..d1318dfc46 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/requisitions.actions.ts @@ -0,0 +1,41 @@ +import { createAction } from '@ngrx/store'; + +import { httpError, payload } from 'ish-core/utils/ngrx-creators'; + +import { Requisition, RequisitionStatus, RequisitionViewer } from '../../models/requisition/requisition.model'; + +export const loadRequisitions = createAction( + '[Requisitions] Load Requisitions', + payload<{ view?: RequisitionViewer; status?: RequisitionStatus }>() +); + +export const loadRequisitionsFail = createAction('[Requisitions API] Load Requisitions Fail', httpError()); + +export const loadRequisitionsSuccess = createAction( + '[Requisitions API] Load Requisitions Success', + payload<{ requisitions: Requisition[]; view?: RequisitionViewer; status?: RequisitionStatus }>() +); + +export const loadRequisition = createAction('[Requisitions] Load Requisition', payload<{ requisitionId: string }>()); + +export const loadRequisitionFail = createAction('[Requisitions API] Load Requisition Fail', httpError()); + +export const loadRequisitionSuccess = createAction( + '[Requisitions API] Load Requisition Success', + payload<{ requisition: Requisition }>() +); + +export const updateRequisitionStatus = createAction( + '[Requisitions] Update Requisition Status', + payload<{ requisitionId: string; status: RequisitionStatus; approvalComment?: string }>() +); + +export const updateRequisitionStatusFail = createAction( + '[Requisitions API] Update Requisition Status Fail', + httpError() +); + +export const updateRequisitionStatusSuccess = createAction( + '[Requisitions API] Update Requisition Status Success', + payload<{ requisition: Requisition }>() +); diff --git a/projects/requisition-management/src/app/store/requisitions/requisitions.effects.spec.ts b/projects/requisition-management/src/app/store/requisitions/requisitions.effects.spec.ts new file mode 100644 index 0000000000..16b164bf71 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/requisitions.effects.spec.ts @@ -0,0 +1,163 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { Observable, of } from 'rxjs'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; + +import { LineItem } from 'ish-core/models/line-item/line-item.model'; + +import { Requisition } from '../../models/requisition/requisition.model'; +import { RequisitionsService } from '../../services/requisitions/requisitions.service'; + +import { + loadRequisition, + loadRequisitionSuccess, + loadRequisitions, + updateRequisitionStatus, +} from './requisitions.actions'; +import { RequisitionsEffects } from './requisitions.effects'; + +@Component({ template: 'dummy' }) +class DummyComponent {} + +const requisitions = [ + { + id: 'testUUID', + requisitionNo: '0001', + user: { firstName: 'Patricia', lastName: 'Miller' }, + approval: { status: 'pending', statusCode: 'PENDING' }, + lineItems: [ + ({ + id: 'BIID', + name: 'NAME', + position: 1, + quantity: { value: 1 }, + price: undefined, + productSKU: 'SKU', + } as unknown) as LineItem, + ], + }, + { + id: 'testUUID2', + requisitionNo: '0002', + user: { firstName: 'Jack', lastName: 'Miller' }, + approval: { status: 'pending', statusCode: 'PENDING' }, + }, +] as Requisition[]; + +describe('Requisitions Effects', () => { + let actions$: Observable; + let effects: RequisitionsEffects; + let requisitionsService: RequisitionsService; + + beforeEach(() => { + requisitionsService = mock(RequisitionsService); + when(requisitionsService.getRequisitions(anything(), anything())).thenReturn(of(requisitions)); + when(requisitionsService.getRequisition(anyString())).thenReturn(of(requisitions[0])); + when(requisitionsService.updateRequisitionStatus(anyString(), anyString(), anyString())).thenReturn( + of(requisitions[0]) + ); + + TestBed.configureTestingModule({ + declarations: [DummyComponent], + imports: [RouterTestingModule.withRoutes([{ path: '**', component: DummyComponent }])], + providers: [ + RequisitionsEffects, + provideMockActions(() => actions$), + { provide: RequisitionsService, useFactory: () => instance(requisitionsService) }, + ], + }); + + effects = TestBed.inject(RequisitionsEffects); + }); + + describe('loadRequisitions$', () => { + it('should call the service for retrieving requisitions', done => { + actions$ = of(loadRequisitions({ view: 'buyer', status: 'PENDING' })); + + effects.loadRequisitions$.subscribe(() => { + verify(requisitionsService.getRequisitions(anything(), anything())).once(); + done(); + }); + }); + + it('should retrieve requisitions when triggered', done => { + actions$ = of(loadRequisitions({ view: 'buyer', status: 'PENDING' })); + + effects.loadRequisitions$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Requisitions API] Load Requisitions Success: + requisitions: [{"id":"testUUID","requisitionNo":"0001","user":{"firstName"... + view: "buyer" + status: "PENDING" + `); + done(); + }); + }); + }); + + describe('loadRequisition$', () => { + it('should call the service for retrieving a requisition', done => { + actions$ = of(loadRequisition({ requisitionId: '12345' })); + + effects.loadRequisition$.subscribe(() => { + verify(requisitionsService.getRequisition('12345')).once(); + done(); + }); + }); + + it('should retrieve a requisition when triggered', done => { + actions$ = of(loadRequisition({ requisitionId: '12345' })); + + effects.loadRequisition$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Requisitions API] Load Requisition Success: + requisition: {"id":"testUUID","requisitionNo":"0001","user":{"firstName":... + `); + done(); + }); + }); + + it('should load products of a requisition if there are not loaded yet', done => { + actions$ = of(loadRequisitionSuccess({ requisition: requisitions[0] })); + + effects.loadProductsForSelectedRequisition$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Products Internal] Load Product if not Loaded: + sku: "SKU" + level: 2 + `); + done(); + }); + }); + }); + + describe('updateRequisitionStatus$', () => { + it('should call the service for updating the status of a requisition', done => { + actions$ = of( + updateRequisitionStatus({ requisitionId: '4711', status: 'APPROVED', approvalComment: 'test comment' }) + ); + + effects.updateRequisitionStatus$.subscribe(() => { + verify(requisitionsService.updateRequisitionStatus('4711', 'APPROVED', 'test comment')).once(); + done(); + }); + }); + + it('should retrieve the requisition after updating the status', done => { + actions$ = of( + updateRequisitionStatus({ requisitionId: '4711', status: 'APPROVED', approvalComment: 'test comment' }) + ); + + effects.updateRequisitionStatus$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Requisitions API] Update Requisition Status Success: + requisition: {"id":"testUUID","requisitionNo":"0001","user":{"firstName":... + `); + done(); + }); + }); + }); +}); diff --git a/projects/requisition-management/src/app/store/requisitions/requisitions.effects.ts b/projects/requisition-management/src/app/store/requisitions/requisitions.effects.ts new file mode 100644 index 0000000000..123485db47 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/requisitions.effects.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { concatMap, map, switchMap, tap } from 'rxjs/operators'; + +import { ProductCompletenessLevel } from 'ish-core/models/product/product.model'; +import { loadProductIfNotLoaded } from 'ish-core/store/shopping/products'; +import { mapErrorToAction, mapToPayload, mapToPayloadProperty } from 'ish-core/utils/operators'; + +import { RequisitionsService } from '../../services/requisitions/requisitions.service'; + +import { + loadRequisition, + loadRequisitionFail, + loadRequisitionSuccess, + loadRequisitions, + loadRequisitionsFail, + loadRequisitionsSuccess, + updateRequisitionStatus, + updateRequisitionStatusFail, + updateRequisitionStatusSuccess, +} from './requisitions.actions'; + +@Injectable() +export class RequisitionsEffects { + constructor(private actions$: Actions, private requisitionsService: RequisitionsService, private router: Router) {} + + loadRequisitions$ = createEffect(() => + this.actions$.pipe( + ofType(loadRequisitions), + mapToPayload(), + concatMap(({ view, status }) => + this.requisitionsService.getRequisitions(view, status).pipe( + map(requisitions => loadRequisitionsSuccess({ requisitions, view, status })), + mapErrorToAction(loadRequisitionsFail) + ) + ) + ) + ); + + loadRequisition$ = createEffect(() => + this.actions$.pipe( + ofType(loadRequisition), + mapToPayload(), + switchMap(({ requisitionId }) => + this.requisitionsService.getRequisition(requisitionId).pipe( + map(requisition => loadRequisitionSuccess({ requisition })), + mapErrorToAction(loadRequisitionFail) + ) + ) + ) + ); + + /** + * After selecting and successfully loading a requisition, triggers a LoadProduct action + * for each product that is missing in the current product entities state. + */ + loadProductsForSelectedRequisition$ = createEffect(() => + this.actions$.pipe( + ofType(loadRequisitionSuccess), + mapToPayloadProperty('requisition'), + switchMap(requisition => [ + ...requisition.lineItems.map(({ productSKU }) => + loadProductIfNotLoaded({ sku: productSKU, level: ProductCompletenessLevel.List }) + ), + ]) + ) + ); + + updateRequisitionStatus$ = createEffect(() => + this.actions$.pipe( + ofType(updateRequisitionStatus), + mapToPayload(), + concatMap(payload => + this.requisitionsService + .updateRequisitionStatus(payload.requisitionId, payload.status, payload.approvalComment) + .pipe( + tap(requisition => + /* ToDo: use only relative routes */ + this.router.navigate([ + `/account/requisitions/approver/${requisition.id}`, + { status: requisition.approval?.statusCode }, + ]) + ), + map(requisition => updateRequisitionStatusSuccess({ requisition })), + mapErrorToAction(updateRequisitionStatusFail) + ) + ) + ) + ); +} diff --git a/projects/requisition-management/src/app/store/requisitions/requisitions.reducer.ts b/projects/requisition-management/src/app/store/requisitions/requisitions.reducer.ts new file mode 100644 index 0000000000..3e367eb1f3 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/requisitions.reducer.ts @@ -0,0 +1,66 @@ +import { EntityState, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; + +import { Requisition } from '../../models/requisition/requisition.model'; + +import { + loadRequisition, + loadRequisitionFail, + loadRequisitionSuccess, + loadRequisitions, + loadRequisitionsFail, + loadRequisitionsSuccess, + updateRequisitionStatus, + updateRequisitionStatusFail, + updateRequisitionStatusSuccess, +} from './requisitions.actions'; + +export const requisitionsAdapter = createEntityAdapter(); + +export interface RequisitionsState extends EntityState { + loading: boolean; + error: HttpError; + filters: { + buyerPENDING: string[]; + buyerAPPROVED: string[]; + buyerREJECTED: string[]; + approverPENDING: string[]; + approverAPPROVED: string[]; + approverREJECTED: string[]; + }; +} + +const initialState: RequisitionsState = requisitionsAdapter.getInitialState({ + loading: false, + error: undefined, + filters: { + buyerPENDING: [], + buyerAPPROVED: [], + buyerREJECTED: [], + approverPENDING: [], + approverAPPROVED: [], + approverREJECTED: [], + }, +}); + +export const requisitionsReducer = createReducer( + initialState, + setLoadingOn(loadRequisitions, loadRequisition, updateRequisitionStatus), + unsetLoadingAndErrorOn(loadRequisitionsSuccess, loadRequisitionSuccess, updateRequisitionStatusSuccess), + setErrorOn(loadRequisitionsFail, loadRequisitionFail, updateRequisitionStatusFail), + on(loadRequisitionsSuccess, (state: RequisitionsState, action) => + requisitionsAdapter.upsertMany(action.payload.requisitions, { + ...state, + filters: { + ...state.filters, + [action.payload.view + action.payload.status]: action.payload.requisitions.map(requisition => requisition.id), + }, + }) + ), + on(loadRequisitionSuccess, updateRequisitionStatusSuccess, (state: RequisitionsState, action) => + requisitionsAdapter.upsertOne(action.payload.requisition, state) + ) +); diff --git a/projects/requisition-management/src/app/store/requisitions/requisitions.selectors.spec.ts b/projects/requisition-management/src/app/store/requisitions/requisitions.selectors.spec.ts new file mode 100644 index 0000000000..f9a0a18d89 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/requisitions.selectors.spec.ts @@ -0,0 +1,182 @@ +import { TestBed } from '@angular/core/testing'; + +import { LineItem } from 'ish-core/models/line-item/line-item.model'; +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; + +import { Requisition, RequisitionApproval } from '../../models/requisition/requisition.model'; +import { RequisitionManagementStoreModule } from '../requisition-management-store.module'; + +import { + loadRequisition, + loadRequisitionSuccess, + loadRequisitions, + loadRequisitionsFail, + loadRequisitionsSuccess, +} from './requisitions.actions'; +import { + getRequisitions, + getRequisitionsError, + getRequisitionsLoading, + selectEntities, +} from './requisitions.selectors'; + +describe('Requisitions Selectors', () => { + let store$: StoreWithSnapshots; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), RequisitionManagementStoreModule.forTesting('requisitions')], + providers: [provideStoreSnapshots()], + }); + + store$ = TestBed.inject(StoreWithSnapshots); + }); + + describe('initial state', () => { + it('should not be loading when in initial state', () => { + expect(getRequisitionsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when in initial state', () => { + expect(getRequisitionsError(store$.state)).toBeUndefined(); + }); + + it('should not have entities when in initial state', () => { + expect(selectEntities(store$.state)).toBeEmpty(); + }); + }); + + describe('LoadRequisitions', () => { + const action = loadRequisitions({ view: 'buyer', status: 'PENDING' }); + + beforeEach(() => { + store$.dispatch(action); + }); + + it('should set loading to true', () => { + expect(getRequisitionsLoading(store$.state)).toBeTrue(); + }); + + describe('loadRequisitionsSuccess', () => { + const requisitions = [{ id: '1' }, { id: '2' }] as Requisition[]; + const successAction = loadRequisitionsSuccess({ requisitions }); + + beforeEach(() => { + store$.dispatch(successAction); + }); + + it('should set loading to false', () => { + expect(getRequisitionsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when successfully loaded entities', () => { + expect(getRequisitionsError(store$.state)).toBeUndefined(); + }); + + it('should have entities when successfully loading', () => { + expect(selectEntities(store$.state)).not.toBeEmpty(); + }); + }); + + describe('loadRequisitionsFail', () => { + beforeEach(() => { + store$.dispatch(loadRequisitionsFail({ error: makeHttpError({ message: 'error' }) })); + }); + + it('should set loading to false', () => { + expect(getRequisitionsLoading(store$.state)).toBeFalse(); + }); + + it('should have an error when reducing', () => { + expect(getRequisitionsError(store$.state)).toBeTruthy(); + }); + + it('should not have entities when reducing error', () => { + expect(selectEntities(store$.state)).toBeEmpty(); + }); + }); + }); + + describe('LoadRequisition', () => { + const action = loadRequisition({ requisitionId: '12345' }); + + beforeEach(() => { + store$.dispatch(action); + }); + + it('should set loading to true', () => { + expect(getRequisitionsLoading(store$.state)).toBeTrue(); + }); + + describe('loadRequisitionSuccess', () => { + const requisition = { + id: '1', + lineItems: [{ id: 'test', productSKU: 'sku', quantity: { value: 5 } } as LineItem], + } as Requisition; + const successAction = loadRequisitionSuccess({ requisition }); + + beforeEach(() => { + store$.dispatch(successAction); + }); + + it('should set loading to false', () => { + expect(getRequisitionsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when successfully loaded entities', () => { + expect(getRequisitionsError(store$.state)).toBeUndefined(); + }); + + it('should have entities when successfully loading', () => { + expect(selectEntities(store$.state)).not.toBeEmpty(); + }); + }); + + describe('loadRequisitionFail', () => { + beforeEach(() => { + store$.dispatch(loadRequisitionsFail({ error: makeHttpError({ message: 'error' }) })); + }); + + it('should set loading to false', () => { + expect(getRequisitionsLoading(store$.state)).toBeFalse(); + }); + + it('should have an error when reducing', () => { + expect(getRequisitionsError(store$.state)).toBeTruthy(); + }); + }); + }); + + describe('getBuyerPendingRequisitions', () => { + const requisitions = [ + { + id: '1', + lineItems: [{ id: 'test1', productSKU: 'sku1', quantity: { value: 5 } } as LineItem], + user: { email: 'testmail@intershop.de' }, + approval: { + statusCode: 'PENDING', + status: 'pending', + } as RequisitionApproval, + } as Requisition, + { + id: '2', + lineItems: [{ id: 'test2', productSKU: 'sku2', quantity: { value: 1 } } as LineItem], + user: { email: 'testmail@intershop.de' }, + approval: { + statusCode: 'PENDING', + status: 'pending', + } as RequisitionApproval, + } as Requisition, + ]; + + beforeEach(() => { + store$.dispatch(loadRequisitionsSuccess({ requisitions, view: 'buyer', status: 'PENDING' })); + }); + + it('should return correct buyer requisitions for the user', () => { + expect(getRequisitions('buyer', 'PENDING')(store$.state)).toEqual(requisitions); + }); + }); +}); diff --git a/projects/requisition-management/src/app/store/requisitions/requisitions.selectors.ts b/projects/requisition-management/src/app/store/requisitions/requisitions.selectors.ts new file mode 100644 index 0000000000..ef1f505ee4 --- /dev/null +++ b/projects/requisition-management/src/app/store/requisitions/requisitions.selectors.ts @@ -0,0 +1,23 @@ +import { createSelector } from '@ngrx/store'; + +import { RequisitionStatus, RequisitionViewer } from '../../models/requisition/requisition.model'; +import { getRequisitionManagementState } from '../requisition-management-store'; + +import { requisitionsAdapter } from './requisitions.reducer'; + +const getRequisitionsState = createSelector(getRequisitionManagementState, state => state.requisitions); + +export const getRequisitionsLoading = createSelector(getRequisitionsState, state => state.loading); + +export const getRequisitionsError = createSelector(getRequisitionsState, state => state.error); + +const getRequisitionsFilters = createSelector(getRequisitionsState, state => state.filters); + +export const { selectEntities } = requisitionsAdapter.getSelectors(getRequisitionsState); + +export const getRequisitions = (view: RequisitionViewer, status: RequisitionStatus) => + createSelector(selectEntities, getRequisitionsFilters, (requisitions, filters) => + filters[view + status].map(id => requisitions[id]) + ); + +export const getRequisition = (id: string) => createSelector(selectEntities, requisitions => requisitions[id]); diff --git a/projects/requisition-management/src/index.html b/projects/requisition-management/src/index.html new file mode 100644 index 0000000000..269111aba3 --- /dev/null +++ b/projects/requisition-management/src/index.html @@ -0,0 +1,13 @@ + + + + + Requisition Management + + + + + + + + diff --git a/projects/requisition-management/src/login.component.ts b/projects/requisition-management/src/login.component.ts new file mode 100644 index 0000000000..9523247d67 --- /dev/null +++ b/projects/requisition-management/src/login.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + template: ` +
+

{{ 'account.requisitions.management' | translate }}

+ +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// tslint:disable-next-line: component-creation-test +export class LoginComponent {} diff --git a/projects/requisition-management/src/main.ts b/projects/requisition-management/src/main.ts new file mode 100644 index 0000000000..a847137952 --- /dev/null +++ b/projects/requisition-management/src/main.ts @@ -0,0 +1,7 @@ +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/projects/requisition-management/tsconfig.app.json b/projects/requisition-management/tsconfig.app.json new file mode 100644 index 0000000000..f3466c0de8 --- /dev/null +++ b/projects/requisition-management/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": ["node"] + }, + "files": ["src/main.ts", "../../src/polyfills.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/scripts/clean-up-localizations.js b/scripts/clean-up-localizations.js index 0397aa8049..0f17919c32 100644 --- a/scripts/clean-up-localizations.js +++ b/scripts/clean-up-localizations.js @@ -7,7 +7,7 @@ const localizationFile_default = 'src/assets/i18n/en_US.json'; // regular expression for patterns of not explicitly used localization keys (dynamic created keys, error keys from REST calls) // ADDITIONAL PATTERNS HAVE TO BE ADDED HERE -const regEx = /account\.login\..*\.message|.*\.error.*/i; +const regEx = /account\.login\..*\.message|.*budget.period..*|account\.budget\.type\..*|.*\.error.*/i; // store localizations from default localization file in an object const localizations_default = JSON.parse(fs.readFileSync(localizationFile_default, 'utf8')); diff --git a/src/app/core/facades/checkout.facade.ts b/src/app/core/facades/checkout.facade.ts index ce42ac6101..60c2d383db 100644 --- a/src/app/core/facades/checkout.facade.ts +++ b/src/app/core/facades/checkout.facade.ts @@ -28,6 +28,7 @@ import { getBasketShippingAddress, getBasketValidationResults, getCurrentBasket, + getSubmittedBasket, isBasketInvoiceAndShippingAddressEqual, loadBasketEligiblePaymentMethods, loadBasketEligibleShippingMethods, @@ -67,6 +68,7 @@ export class CheckoutFacade { basketLineItems$ = this.basket$.pipe( map(basket => (basket && basket.lineItems && basket.lineItems.length ? basket.lineItems : undefined)) ); + submittedBasket$ = this.store.pipe(select(getSubmittedBasket)); deleteBasketItem(itemId: string) { this.store.dispatch(deleteBasketItem({ itemId })); diff --git a/src/app/core/icon.module.ts b/src/app/core/icon.module.ts index 77152ea321..9eeef4fa77 100644 --- a/src/app/core/icon.module.ts +++ b/src/app/core/icon.module.ts @@ -40,6 +40,7 @@ import { faTrashAlt, faUndo, faUser, + faUserCheck, } from '@fortawesome/free-solid-svg-icons'; @NgModule({ @@ -84,6 +85,7 @@ export class IconModule { faTrashAlt, faUndo, faUser, + faUserCheck, faStar, faStarHalf, faHeart, diff --git a/src/app/core/models/basket-approval/basket-approval.model.ts b/src/app/core/models/basket-approval/basket-approval.model.ts new file mode 100644 index 0000000000..c1b9ab48ab --- /dev/null +++ b/src/app/core/models/basket-approval/basket-approval.model.ts @@ -0,0 +1,11 @@ +export interface BasketApproval { + approvalRequired: boolean; + customerApproval?: { + approvers?: { + email: string; + firstName: string; + lastName: string; + title?: string; + }[]; + }; +} diff --git a/src/app/core/models/basket/basket.interface.ts b/src/app/core/models/basket/basket.interface.ts index 8c5118f28b..34ed947f5a 100644 --- a/src/app/core/models/basket/basket.interface.ts +++ b/src/app/core/models/basket/basket.interface.ts @@ -1,4 +1,5 @@ import { AddressData } from 'ish-core/models/address/address.interface'; +import { BasketApproval } from 'ish-core/models/basket-approval/basket-approval.model'; import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { BasketRebateData } from 'ish-core/models/basket-rebate/basket-rebate.interface'; import { BasketTotalData } from 'ish-core/models/basket-total/basket-total.interface'; @@ -17,6 +18,7 @@ export interface BasketBaseData { commonShipToAddress?: string; commonShippingMethod?: string; customer?: string; + user?: string; discounts?: { dynamicMessages?: string[]; shippingBasedDiscounts?: string[]; @@ -40,6 +42,7 @@ export interface BasketBaseData { name: string; }[]; }; + approval?: BasketApproval; } export interface BasketData { diff --git a/src/app/core/models/basket/basket.mapper.spec.ts b/src/app/core/models/basket/basket.mapper.spec.ts index 2231618b29..4a9896cd6a 100644 --- a/src/app/core/models/basket/basket.mapper.spec.ts +++ b/src/app/core/models/basket/basket.mapper.spec.ts @@ -17,6 +17,7 @@ describe('Basket Mapper', () => { commonShippingMethod: 'shipping_method_123', customer: 'Heimroth', lineItems: ['YikKAE8BKC0AAAFrIW8IyLLD'], + approval: { approvalRequired: true }, totals: { grandTotal: { gross: { @@ -235,6 +236,11 @@ describe('Basket Mapper', () => { basket = BasketMapper.fromData(basketData); expect(basket.totals.isEstimated).toBeFalse(); }); + + it('should return approval data if approval data are set', () => { + basket = BasketMapper.fromData(basketData); + expect(basket.approval.approvalRequired).toBeTrue(); + }); }); describe('getTotals', () => { diff --git a/src/app/core/models/basket/basket.mapper.ts b/src/app/core/models/basket/basket.mapper.ts index 6af0942cb2..db997741f4 100644 --- a/src/app/core/models/basket/basket.mapper.ts +++ b/src/app/core/models/basket/basket.mapper.ts @@ -39,6 +39,7 @@ export class BasketMapper { ? ShippingMethodMapper.fromData(included.commonShippingMethod[data.commonShippingMethod]) : undefined, customerNo: data.customer, + email: data.user, lineItems: included && included.lineItems && data.lineItems && data.lineItems.length ? data.lineItems.map(lineItemId => @@ -62,6 +63,7 @@ export class BasketMapper { promotionCodes: data.promotionCodes, totals, infos: infos && infos.filter(info => info.code !== 'include.not_resolved.error'), + approval: data.approval, }; } diff --git a/src/app/core/models/basket/basket.model.ts b/src/app/core/models/basket/basket.model.ts index efd9afcbe6..60e3950d35 100644 --- a/src/app/core/models/basket/basket.model.ts +++ b/src/app/core/models/basket/basket.model.ts @@ -1,4 +1,5 @@ import { Address } from 'ish-core/models/address/address.model'; +import { BasketApproval } from 'ish-core/models/basket-approval/basket-approval.model'; import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { BasketTotal } from 'ish-core/models/basket-total/basket-total.model'; import { BasketValidationResultType } from 'ish-core/models/basket-validation/basket-validation.model'; @@ -14,6 +15,7 @@ export interface AbstractBasket { commonShipToAddress?: Address; commonShippingMethod?: ShippingMethod; customerNo?: string; + email?: string; lineItems?: T[]; payment?: Payment; promotionCodes?: string[]; @@ -21,6 +23,7 @@ export interface AbstractBasket { totalProductQuantity?: number; bucketId?: string; infos?: BasketInfo[]; + approval?: BasketApproval; } export interface Basket extends 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 2877cb2e94..f8548c589e 100644 --- a/src/app/core/models/breadcrumb-item/breadcrumb-item.interface.ts +++ b/src/app/core/models/breadcrumb-item/breadcrumb-item.interface.ts @@ -1,5 +1,6 @@ export interface BreadcrumbItem { key?: string; text?: string; - link?: string; + // tslint:disable-next-line:no-any + link?: string | any[]; } diff --git a/src/app/core/models/order/order.interface.ts b/src/app/core/models/order/order.interface.ts index b23f427d24..a5c0696933 100644 --- a/src/app/core/models/order/order.interface.ts +++ b/src/app/core/models/order/order.interface.ts @@ -1,4 +1,5 @@ import { AddressData } from 'ish-core/models/address/address.interface'; +import { Attribute } from 'ish-core/models/attribute/attribute.model'; import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { BasketRebateData } from 'ish-core/models/basket-rebate/basket-rebate.interface'; import { BasketBaseData } from 'ish-core/models/basket/basket.interface'; @@ -21,6 +22,9 @@ export interface OrderBaseData extends BasketBaseData { }; statusCode: string; status: string; + basket: string; + requisitionDocumentNo?: string; + attributes?: Attribute[]; } export interface OrderData { diff --git a/src/app/core/models/order/order.mapper.spec.ts b/src/app/core/models/order/order.mapper.spec.ts index 30570c6710..824a56a9a6 100644 --- a/src/app/core/models/order/order.mapper.spec.ts +++ b/src/app/core/models/order/order.mapper.spec.ts @@ -16,6 +16,7 @@ describe('Order Mapper', () => { commonShipToAddress: 'urn_commonShipToAddress_123', commonShippingMethod: 'shipping_method_123', lineItems: ['YikKAE8BKC0AAAFrIW8IyLLD'], + requisitionDocumentNo: '58765', totals: { grandTotal: { gross: { @@ -63,6 +64,11 @@ describe('Order Mapper', () => { }, ], }, + attributes: [ + { name: 'BusinessObjectAttributes#OrderApproval_ApprovalDate', value: '2020-10-12T13:40:00+02:00', type: 'Date' }, + { name: 'BusinessObjectAttributes#OrderApproval_ApproverFirstName', value: 'Patricia', type: 'String' }, + { name: 'BusinessObjectAttributes#OrderApproval_ApproverLastName', value: 'Miller', type: 'String' }, + ], } as OrderBaseData; const orderData = { @@ -151,6 +157,9 @@ describe('Order Mapper', () => { expect(order.commonShippingMethod.id).toBe('shipping_method_123'); expect(order.lineItems).toBeArrayOfSize(1); expect(order.infos).toBeArrayOfSize(1); + + expect(order.approval.approverFirstName).toBe('Patricia'); + expect(order.requisitionNo).toBe(orderBaseData.requisitionDocumentNo); }); }); }); diff --git a/src/app/core/models/order/order.mapper.ts b/src/app/core/models/order/order.mapper.ts index dce71a029c..3169974e5a 100644 --- a/src/app/core/models/order/order.mapper.ts +++ b/src/app/core/models/order/order.mapper.ts @@ -1,4 +1,5 @@ import { AddressMapper } from 'ish-core/models/address/address.mapper'; +import { AttributeHelper } from 'ish-core/models/attribute/attribute.helper'; 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'; @@ -20,7 +21,28 @@ export class OrderMapper { orderCreation: data.orderCreation, statusCode: data.statusCode, status: data.status, - + requisitionNo: data.requisitionDocumentNo, + approval: + data.attributes && + AttributeHelper.getAttributeValueByAttributeName( + data.attributes, + 'BusinessObjectAttributes#OrderApproval_ApprovalDate' + ) + ? { + date: AttributeHelper.getAttributeValueByAttributeName( + data.attributes, + 'BusinessObjectAttributes#OrderApproval_ApprovalDate' + ), + approverFirstName: AttributeHelper.getAttributeValueByAttributeName( + data.attributes, + 'BusinessObjectAttributes#OrderApproval_ApproverFirstName' + ), + approverLastName: AttributeHelper.getAttributeValueByAttributeName( + data.attributes, + 'BusinessObjectAttributes#OrderApproval_ApproverLastName' + ), + } + : undefined, purchaseCurrency: data.purchaseCurrency, dynamicMessages: data.discounts ? data.discounts.dynamicMessages : undefined, invoiceToAddress: @@ -35,6 +57,8 @@ export class OrderMapper { included && included.commonShippingMethod && data.commonShippingMethod ? ShippingMethodMapper.fromData(included.commonShippingMethod[data.commonShippingMethod]) : undefined, + customerNo: data.customer, + email: data.user, lineItems: included && included.lineItems && data.lineItems && data.lineItems.length ? data.lineItems.map(lineItemId => diff --git a/src/app/core/models/order/order.model.ts b/src/app/core/models/order/order.model.ts index 036e1ab06b..2b1c3d2c13 100644 --- a/src/app/core/models/order/order.model.ts +++ b/src/app/core/models/order/order.model.ts @@ -7,7 +7,9 @@ export interface OrderLineItem extends LineItem { fulfillmentStatus: string; } -export interface Order extends AbstractBasket { +type OrderBasket = Omit, 'approval'>; + +export interface Order extends OrderBasket { documentNo: string; creationDate: number; orderCreation: { @@ -20,4 +22,10 @@ export interface Order extends AbstractBasket { }; statusCode: string; status: string; + approval?: { + approverFirstName: string; + approverLastName: string; + date: number; + }; + requisitionNo?: string; } diff --git a/src/app/core/models/price/price.helper.spec.ts b/src/app/core/models/price/price.helper.spec.ts index 1ff96577ff..9f3354731d 100644 --- a/src/app/core/models/price/price.helper.spec.ts +++ b/src/app/core/models/price/price.helper.spec.ts @@ -126,4 +126,12 @@ describe('Price Helper', () => { expect(invertedPrice.net).toEqual(-8); }); }); + + describe('empty', () => { + it('should always return an empty price with the right currency', () => { + const emptyPrice = PriceHelper.empty('USD'); + expect(emptyPrice.currency).toEqual('USD'); + expect(emptyPrice.value).toEqual(0); + }); + }); }); diff --git a/src/app/core/models/price/price.helper.ts b/src/app/core/models/price/price.helper.ts index dee481cca4..615632c657 100644 --- a/src/app/core/models/price/price.helper.ts +++ b/src/app/core/models/price/price.helper.ts @@ -58,4 +58,12 @@ export class PriceHelper { value: Math.round((p1.value + p2.value) * 100) / 100, }; } + + static empty(currency?: string): Price { + return { + type: 'Money', + value: 0, + currency, + }; + } } diff --git a/src/app/core/pipes/server-setting.pipe.spec.ts b/src/app/core/pipes/server-setting.pipe.spec.ts index 031fe8452f..de8a85046f 100644 --- a/src/app/core/pipes/server-setting.pipe.spec.ts +++ b/src/app/core/pipes/server-setting.pipe.spec.ts @@ -8,7 +8,11 @@ import { AppFacade } from 'ish-core/facades/app.facade'; import { ServerSettingPipe } from './server-setting.pipe'; -@Component({ template: `TEST` }) +@Component({ + template: `TEST + [always] + [never]`, +}) class TestComponent {} describe('Server Setting Pipe', () => { @@ -33,38 +37,38 @@ describe('Server Setting Pipe', () => { when(appFacade.serverSetting$('service.ABC.runnable')).thenReturn(of(true)); fixture.detectChanges(); - expect(element).toMatchInlineSnapshot(`TEST`); + expect(element).toMatchInlineSnapshot(`TEST[always]`); }); it('should render TEST when setting is set to anything truthy', () => { when(appFacade.serverSetting$('service.ABC.runnable')).thenReturn(of('ABC')); fixture.detectChanges(); - expect(element).toMatchInlineSnapshot(`TEST`); + expect(element).toMatchInlineSnapshot(`TEST[always]`); }); it('should render nothing when setting is not set', () => { when(appFacade.serverSetting$(anything())).thenReturn(of(undefined)); fixture.detectChanges(); - expect(element).toMatchInlineSnapshot(`N/A`); + expect(element).toMatchInlineSnapshot(`[always]`); }); it('should render nothing when setting is set to sth falsy', () => { when(appFacade.serverSetting$(anything())).thenReturn(of('')); fixture.detectChanges(); - expect(element).toMatchInlineSnapshot(`N/A`); + expect(element).toMatchInlineSnapshot(`[always]`); }); it('should render TEST when setting is set', fakeAsync(() => { when(appFacade.serverSetting$('service.ABC.runnable')).thenReturn(concat(of(false), of(true).pipe(delay(1000)))); fixture.detectChanges(); - expect(element).toMatchInlineSnapshot(`N/A`); + expect(element).toMatchInlineSnapshot(`[always]`); tick(1000); fixture.detectChanges(); - expect(element).toMatchInlineSnapshot(`TEST`); + expect(element).toMatchInlineSnapshot(`TEST[always]`); })); }); diff --git a/src/app/core/pipes/server-setting.pipe.ts b/src/app/core/pipes/server-setting.pipe.ts index 8255142f00..91bfadda88 100644 --- a/src/app/core/pipes/server-setting.pipe.ts +++ b/src/app/core/pipes/server-setting.pipe.ts @@ -23,15 +23,19 @@ export class ServerSettingPipe implements PipeTransform, OnDestroy { constructor(private appFacade: AppFacade) {} transform(path: string) { - if (this.sub) { - return this.returnValue; + if (path === 'always') { + return true; + } else if (path === 'never') { + return false; } else { - this.sub = this.appFacade - .serverSetting$(path) - .pipe(takeUntil(this.destroy$)) - .subscribe(value => { - this.returnValue = value; - }); + if (!this.sub) { + this.sub = this.appFacade + .serverSetting$(path) + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.returnValue = value; + }); + } return this.returnValue; } } diff --git a/src/app/core/services/api/api.service.ts b/src/app/core/services/api/api.service.ts index f386b395bb..ab729f720a 100644 --- a/src/app/core/services/api/api.service.ts +++ b/src/app/core/services/api/api.service.ts @@ -297,6 +297,12 @@ export class ApiService { this.put(`customers/${customer.customerNo}/users/${user.login}/${path}`, body, options) ) ), + patch: (path: string, body = {}, options?: AvailableOptions) => + ids$.pipe( + concatMap(([user, customer]) => + this.patch(`customers/${customer.customerNo}/users/${user.login}/${path}`, body, options) + ) + ), post: (path: string, body = {}, options?: AvailableOptions) => ids$.pipe( concatMap(([user, customer]) => diff --git a/src/app/core/services/basket/basket.service.spec.ts b/src/app/core/services/basket/basket.service.spec.ts index fa24bceba8..49dc2a4ced 100644 --- a/src/app/core/services/basket/basket.service.spec.ts +++ b/src/app/core/services/basket/basket.service.spec.ts @@ -4,6 +4,8 @@ import { anyString, anything, capture, instance, mock, verify, when } from 'ts-m import { Address } from 'ish-core/models/address/address.model'; import { ApiService } from 'ish-core/services/api/api.service'; +import { OrderService } from 'ish-core/services/order/order.service'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { BasketItemUpdateType, BasketService } from './basket.service'; @@ -11,6 +13,7 @@ import { BasketItemUpdateType, BasketService } from './basket.service'; describe('Basket Service', () => { let basketService: BasketService; let apiService: ApiService; + let orderService: OrderService; const basketMockData = { data: { @@ -48,7 +51,8 @@ describe('Basket Service', () => { beforeEach(() => { apiService = mock(ApiService); - basketService = new BasketService(instance(apiService)); + orderService = mock(OrderService); + basketService = new BasketService(instance(apiService), instance(orderService)); }); it("should get basket data when 'getBasket' is called", done => { @@ -228,4 +232,15 @@ describe('Basket Service', () => { done(); }); }); + + it("should submit a basket for approval when 'submitBasket' is called", done => { + when(orderService.createOrder(anything(), anything())).thenReturn( + throwError(makeHttpError({ message: 'invalid', status: 422 })) + ); + + basketService.createRequisition('basketId').subscribe(() => { + verify(orderService.createOrder('basketId', anything())).once(); + done(); + }); + }); }); diff --git a/src/app/core/services/basket/basket.service.ts b/src/app/core/services/basket/basket.service.ts index 63a9455acd..2e6d271c21 100644 --- a/src/app/core/services/basket/basket.service.ts +++ b/src/app/core/services/basket/basket.service.ts @@ -1,7 +1,7 @@ import { HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { EMPTY, Observable, throwError } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { EMPTY, Observable, of, throwError } from 'rxjs'; +import { catchError, concatMap, map } from 'rxjs/operators'; import { AddressMapper } from 'ish-core/models/address/address.mapper'; import { Address } from 'ish-core/models/address/address.model'; @@ -19,6 +19,7 @@ import { ShippingMethodData } from 'ish-core/models/shipping-method/shipping-met import { ShippingMethodMapper } from 'ish-core/models/shipping-method/shipping-method.mapper'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; +import { OrderService } from 'ish-core/services/order/order.service'; export type BasketUpdateType = | { invoiceToAddress: string } @@ -70,7 +71,7 @@ type ValidationBasketIncludeType = */ @Injectable({ providedIn: 'root' }) export class BasketService { - constructor(private apiService: ApiService) {} + constructor(private apiService: ApiService, private orderService: OrderService) {} /** * http header for Basket API v1 @@ -402,4 +403,25 @@ export class BasketService { map(data => data.map(ShippingMethodMapper.fromData)) ); } + + /** + * Creates a requisition of a certain basket that has to be approved. + * @param basketId Basket id. + * @returns nothing + */ + createRequisition(basketId: string): Observable { + if (!basketId) { + return throwError('createRequisition() called without required basketId'); + } + + return this.orderService.createOrder(basketId, true).pipe( + concatMap(() => of(undefined)), + catchError(err => { + if (err.status === 422) { + return of(undefined); + } + return throwError(err); + }) + ); + } } diff --git a/src/app/core/store/customer/basket/basket-validation.effects.spec.ts b/src/app/core/store/customer/basket/basket-validation.effects.spec.ts index 6855025597..79e6b07081 100644 --- a/src/app/core/store/customer/basket/basket-validation.effects.spec.ts +++ b/src/app/core/store/customer/basket/basket-validation.effects.spec.ts @@ -12,6 +12,7 @@ import { BasketValidation } from 'ish-core/models/basket-validation/basket-valid import { Product } from 'ish-core/models/product/product.model'; import { BasketService } from 'ish-core/services/basket/basket.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; import { createOrder } from 'ish-core/store/customer/orders'; import { loadProductSuccess } from 'ish-core/store/shopping/products'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; @@ -24,6 +25,7 @@ import { continueCheckoutSuccess, continueCheckoutWithIssues, loadBasketSuccess, + submitBasket, validateBasket, } from './basket.actions'; @@ -44,6 +46,7 @@ describe('Basket Validation Effects', () => { declarations: [DummyComponent], imports: [ CoreStoreModule.forTesting(), + CustomerStoreModule.forTesting('user', 'basket'), RouterTestingModule.withRoutes([ { path: 'checkout', children: [{ path: 'address', component: DummyComponent }] }, ]), @@ -171,8 +174,24 @@ describe('Basket Validation Effects', () => { it('should map to action of type CreateOrder if targetStep is 5 (order creation)', () => { const action = continueCheckout({ targetStep: 5 }); - const completion1 = createOrder(); - const completion2 = continueCheckoutSuccess({ targetRoute: undefined, basketValidation }); + const completion1 = continueCheckoutSuccess({ targetRoute: undefined, basketValidation }); + const completion2 = createOrder(); + actions$ = hot('-a----a----a', { a: action }); + const expected$ = cold('-(cd)-(cd)-(cd)', { c: completion1, d: completion2 }); + + expect(effects.validateBasketAndContinueCheckout$).toBeObservable(expected$); + }); + + it('should map to action of type SubmitBasket if targetStep is 5 (order creation) and approval is required', () => { + store$.dispatch( + loadBasketSuccess({ + basket: { ...BasketMockData.getBasket(), approval: { approvalRequired: true } }, + }) + ); + + const action = continueCheckout({ targetStep: 5 }); + const completion1 = continueCheckoutSuccess({ targetRoute: undefined, basketValidation }); + const completion2 = submitBasket(); actions$ = hot('-a----a----a', { a: action }); const expected$ = cold('-(cd)-(cd)-(cd)', { c: completion1, d: completion2 }); diff --git a/src/app/core/store/customer/basket/basket-validation.effects.ts b/src/app/core/store/customer/basket/basket-validation.effects.ts index 47aa7cef53..fe85e2c216 100644 --- a/src/app/core/store/customer/basket/basket-validation.effects.ts +++ b/src/app/core/store/customer/basket/basket-validation.effects.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { concatMap, filter, map, tap } from 'rxjs/operators'; +import { Store, select } from '@ngrx/store'; +import { concatMap, filter, map, tap, withLatestFrom } from 'rxjs/operators'; import { BasketValidationResultType, @@ -19,12 +20,19 @@ import { continueCheckoutWithIssues, loadBasketEligiblePaymentMethods, loadBasketEligibleShippingMethods, + submitBasket, validateBasket, } from './basket.actions'; +import { getCurrentBasket } from './basket.selectors'; @Injectable() export class BasketValidationEffects { - constructor(private actions$: Actions, private router: Router, private basketService: BasketService) {} + constructor( + private actions$: Actions, + private router: Router, + private store: Store, + private basketService: BasketService + ) {} /** * validates the basket but doesn't change the route @@ -87,10 +95,13 @@ export class BasketValidationEffects { } } return this.basketService.validateBasket(scopes).pipe( - concatMap(basketValidation => + withLatestFrom(this.store.pipe(select(getCurrentBasket))), + concatMap(([basketValidation, basket]) => basketValidation.results.valid ? targetStep === 5 && !basketValidation.results.adjusted - ? [createOrder(), continueCheckoutSuccess({ targetRoute: undefined, basketValidation })] + ? basket.approval?.approvalRequired + ? [continueCheckoutSuccess({ targetRoute: undefined, basketValidation }), submitBasket()] + : [continueCheckoutSuccess({ targetRoute: undefined, basketValidation }), createOrder()] : [continueCheckoutSuccess({ targetRoute, basketValidation })] : [continueCheckoutWithIssues({ targetRoute, basketValidation })] ), diff --git a/src/app/core/store/customer/basket/basket.actions.ts b/src/app/core/store/customer/basket/basket.actions.ts index a5ec1eb853..993d6ab3a5 100644 --- a/src/app/core/store/customer/basket/basket.actions.ts +++ b/src/app/core/store/customer/basket/basket.actions.ts @@ -203,6 +203,12 @@ export const deleteBasketPaymentFail = createAction('[Basket API] Delete Basket export const deleteBasketPaymentSuccess = createAction('[Basket API] Delete Basket Payment Success'); +export const submitBasket = createAction('[Basket API] Submit a Basket for Approval'); + +export const submitBasketSuccess = createAction('[Basket API] Submit a Basket for Approval Success'); + +export const submitBasketFail = createAction('[Basket API] Submit a Basket for Approval Fail', httpError()); + export const resetBasketErrors = createAction('[Basket Internal] Reset Basket and Basket Promotion Errors'); export const updateConcardisCvcLastUpdated = createAction( diff --git a/src/app/core/store/customer/basket/basket.effects.spec.ts b/src/app/core/store/customer/basket/basket.effects.spec.ts index 7aeb964d5a..f086c756c0 100644 --- a/src/app/core/store/customer/basket/basket.effects.spec.ts +++ b/src/app/core/store/customer/basket/basket.effects.spec.ts @@ -6,7 +6,7 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { Action, Store } from '@ngrx/store'; import { cold, hot } from 'jest-marbles'; import { EMPTY, Observable, of, throwError } from 'rxjs'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; import { Basket } from 'ish-core/models/basket/basket.model'; import { BasketService } from 'ish-core/services/basket/basket.service'; @@ -26,6 +26,9 @@ import { loadBasketFail, loadBasketSuccess, resetBasketErrors, + submitBasket, + submitBasketFail, + submitBasketSuccess, updateBasket, updateBasketFail, updateBasketShippingMethod, @@ -278,4 +281,45 @@ describe('Basket Effects', () => { expect(effects.routeListenerForResettingBasketErrors$).toBeObservable(cold('|')); }); }); + + describe('submitBasket$', () => { + beforeEach(() => { + store$.dispatch(loadBasketSuccess({ basket: { id: 'BID' } as Basket })); + }); + + it('should call the basketService for submitBasket', done => { + when(basketServiceMock.createRequisition(anyString())).thenReturn(of(undefined)); + const payload = 'BID'; + const action = submitBasket(); + actions$ = of(action); + + effects.createRequisition$.subscribe(() => { + verify(basketServiceMock.createRequisition(payload)).once(); + done(); + }); + }); + + it('should map a valid request to action of type SubmitBasketBuccess', () => { + when(basketServiceMock.createRequisition(anyString())).thenReturn(of(undefined)); + + const action = submitBasket(); + const completion = submitBasketSuccess(); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.createRequisition$).toBeObservable(expected$); + }); + + it('should map an invalid request to action of type SubmitBasketFail', () => { + when(basketServiceMock.createRequisition(anyString())).thenReturn( + throwError(makeHttpError({ message: 'invalid' })) + ); + const action = submitBasket(); + const completion = submitBasketFail({ error: makeHttpError({ message: 'invalid' }) }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.createRequisition$).toBeObservable(expected$); + }); + }); }); diff --git a/src/app/core/store/customer/basket/basket.effects.ts b/src/app/core/store/customer/basket/basket.effects.ts index 4fd99cc949..201a91bee4 100644 --- a/src/app/core/store/customer/basket/basket.effects.ts +++ b/src/app/core/store/customer/basket/basket.effects.ts @@ -1,9 +1,21 @@ import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { RouterNavigatedPayload, routerNavigatedAction } from '@ngrx/router-store'; import { Store, select } from '@ngrx/store'; import { EMPTY, combineLatest, iif, of } from 'rxjs'; -import { concatMap, filter, map, mapTo, mergeMap, sample, startWith, switchMap, withLatestFrom } from 'rxjs/operators'; +import { + concatMap, + filter, + map, + mapTo, + mergeMap, + sample, + startWith, + switchMap, + tap, + withLatestFrom, +} from 'rxjs/operators'; import { BasketService } from 'ish-core/services/basket/basket.service'; import { RouterState } from 'ish-core/store/core/router/router.reducer'; @@ -22,6 +34,9 @@ import { mergeBasketFail, mergeBasketSuccess, resetBasketErrors, + submitBasket, + submitBasketFail, + submitBasketSuccess, updateBasket, updateBasketFail, updateBasketShippingMethod, @@ -32,9 +47,10 @@ import { getCurrentBasket, getCurrentBasketId } from './basket.selectors'; export class BasketEffects { constructor( private actions$: Actions, - private store: Store, private basketService: BasketService, - private apiTokenService: ApiTokenService + private apiTokenService: ApiTokenService, + private router: Router, + private store: Store ) {} /** @@ -170,4 +186,21 @@ export class BasketEffects { mapTo(resetBasketErrors()) ) ); + + /** + * Creates a requisition based on the given basket, if approval is required + */ + createRequisition$ = createEffect(() => + this.actions$.pipe( + ofType(submitBasket), + withLatestFrom(this.store.select(getCurrentBasketId)), + concatMap(([, basketId]) => + this.basketService.createRequisition(basketId).pipe( + tap(() => this.router.navigate(['/checkout/receipt'])), + map(submitBasketSuccess), + mapErrorToAction(submitBasketFail) + ) + ) + ) + ); } diff --git a/src/app/core/store/customer/basket/basket.reducer.ts b/src/app/core/store/customer/basket/basket.reducer.ts index f77b2ac2cb..0c5cf562e1 100644 --- a/src/app/core/store/customer/basket/basket.reducer.ts +++ b/src/app/core/store/customer/basket/basket.reducer.ts @@ -7,7 +7,7 @@ import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { PaymentMethod } from 'ish-core/models/payment-method/payment-method.model'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; import { createOrderSuccess } from 'ish-core/store/customer/orders'; -import { setErrorOn, setLoadingOn } from 'ish-core/utils/ngrx-creators'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; import { addItemsToBasket, @@ -49,6 +49,9 @@ import { setBasketPayment, setBasketPaymentFail, setBasketPaymentSuccess, + submitBasket, + submitBasketFail, + submitBasketSuccess, updateBasket, updateBasketFail, updateBasketItems, @@ -73,6 +76,7 @@ export interface BasketState { info: BasketInfo[]; lastTimeProductAdded: number; validationResults: BasketValidationResultType; + submittedBasket: Basket; } const initialValidationResults: BasketValidationResultType = { @@ -91,6 +95,7 @@ export const initialState: BasketState = { promotionError: undefined, lastTimeProductAdded: undefined, validationResults: initialValidationResults, + submittedBasket: undefined, }; export const basketReducer = createReducer( @@ -113,8 +118,26 @@ export const basketReducer = createReducer( createBasketPayment, updateBasketPayment, deleteBasketPayment, + submitBasket, updateConcardisCvcLastUpdated ), + unsetLoadingAndErrorOn( + loadBasketSuccess, + mergeBasketSuccess, + updateBasketItemsSuccess, + deleteBasketItemSuccess, + addItemsToBasketSuccess, + setBasketPaymentSuccess, + createBasketPaymentSuccess, + updateBasketPaymentSuccess, + deleteBasketPaymentSuccess, + removePromotionCodeFromBasketSuccess, + continueCheckoutSuccess, + continueCheckoutWithIssues, + loadBasketEligibleShippingMethodsSuccess, + loadBasketEligiblePaymentMethodsSuccess, + submitBasketSuccess + ), setErrorOn( mergeBasketFail, loadBasketFail, @@ -130,61 +153,44 @@ export const basketReducer = createReducer( createBasketPaymentFail, updateBasketPaymentFail, deleteBasketPaymentFail, - updateConcardisCvcLastUpdatedFail + updateConcardisCvcLastUpdatedFail, + submitBasketFail, + updateConcardisCvcLastUpdatedSuccess ), - on(addPromotionCodeToBasketFail, (state: BasketState, action) => { - const { error } = action.payload; + + on(loadBasketSuccess, mergeBasketSuccess, (state: BasketState, action) => { + const basket = { + ...action.payload.basket, + }; return { ...state, - promotionError: error, - loading: false, + basket, + submittedBasket: undefined, }; }), - on(addPromotionCodeToBasketSuccess, (state: BasketState) => ({ - ...state, - loading: false, - promotionError: undefined, - })), on(updateBasketItemsSuccess, deleteBasketItemSuccess, (state: BasketState, action) => ({ ...state, - loading: false, - error: undefined, info: action.payload.info, validationResults: initialValidationResults, })), + on(addItemsToBasketSuccess, (state: BasketState, action) => ({ + ...state, + info: action.payload.info, + lastTimeProductAdded: new Date().getTime(), + submittedBasket: undefined, + })), on( - removePromotionCodeFromBasketSuccess, setBasketPaymentSuccess, createBasketPaymentSuccess, updateBasketPaymentSuccess, deleteBasketPaymentSuccess, + removePromotionCodeFromBasketSuccess, (state: BasketState) => ({ ...state, - loading: false, - error: undefined, validationResults: initialValidationResults, }) ), - on(addItemsToBasketSuccess, (state: BasketState, action) => ({ - ...state, - loading: false, - error: undefined, - info: action.payload.info, - lastTimeProductAdded: new Date().getTime(), - })), - on(mergeBasketSuccess, loadBasketSuccess, (state: BasketState, action) => { - const basket = { - ...action.payload.basket, - }; - - return { - ...state, - basket, - loading: false, - error: undefined, - }; - }), on(continueCheckoutSuccess, continueCheckoutWithIssues, (state: BasketState, action) => { const validation = action.payload.basketValidation; const basket = validation && validation.results.adjusted && validation.basket ? validation.basket : state.basket; @@ -192,33 +198,19 @@ export const basketReducer = createReducer( return { ...state, basket, - loading: false, - error: undefined, info: undefined, + submittedBasket: undefined, validationResults: validation && validation.results, }; }), on(loadBasketEligibleShippingMethodsSuccess, (state: BasketState, action) => ({ ...state, eligibleShippingMethods: action.payload.shippingMethods, - loading: false, - error: undefined, })), on(loadBasketEligiblePaymentMethodsSuccess, (state: BasketState, action) => ({ ...state, eligiblePaymentMethods: action.payload.paymentMethods, - loading: false, - error: undefined, - })), - on(createOrderSuccess, () => initialState), - on(resetBasketErrors, (state: BasketState) => ({ - ...state, - error: undefined, - info: undefined, - promotionError: undefined, - validationResults: initialValidationResults, })), - on(updateConcardisCvcLastUpdatedSuccess, (state: BasketState, action) => ({ ...state, basket: { @@ -228,7 +220,38 @@ export const basketReducer = createReducer( paymentInstrument: action.payload.paymentInstrument, }, }, + })), + on(addPromotionCodeToBasketSuccess, (state: BasketState) => ({ + ...state, loading: false, + promotionError: undefined, + })), + + on(addPromotionCodeToBasketFail, (state: BasketState, action) => { + const { error } = action.payload; + + return { + ...state, + promotionError: error, + loading: false, + }; + }), + + on(createOrderSuccess, () => initialState), + on(submitBasketSuccess, (state: BasketState) => ({ + ...state, + submittedBasket: state.basket, + basket: undefined, + info: undefined, + promotionError: undefined, + validationResults: initialValidationResults, + })), + + on(resetBasketErrors, (state: BasketState) => ({ + ...state, error: undefined, + info: undefined, + promotionError: undefined, + validationResults: initialValidationResults, })) ); diff --git a/src/app/core/store/customer/basket/basket.selectors.spec.ts b/src/app/core/store/customer/basket/basket.selectors.spec.ts index 9add8b006c..921c405998 100644 --- a/src/app/core/store/customer/basket/basket.selectors.spec.ts +++ b/src/app/core/store/customer/basket/basket.selectors.spec.ts @@ -27,6 +27,7 @@ import { loadBasketEligibleShippingMethodsSuccess, loadBasketFail, loadBasketSuccess, + submitBasketSuccess, } from './basket.actions'; import { getBasketEligiblePaymentMethods, @@ -39,6 +40,7 @@ import { getBasketValidationResults, getCurrentBasket, getCurrentBasketId, + getSubmittedBasket, } from './basket.selectors'; describe('Basket Selectors', () => { @@ -67,6 +69,10 @@ describe('Basket Selectors', () => { expect(getBasketEligiblePaymentMethods(store$.state)).toBeUndefined(); }); + it('should not select a submitted basket if it is in initial state', () => { + expect(getSubmittedBasket(store$.state)).toBeUndefined(); + }); + it('should not select loading, error and lastTimeProductAdded if it is in initial state', () => { expect(getBasketLoading(store$.state)).toBeFalse(); expect(getBasketError(store$.state)).toBeUndefined(); @@ -299,4 +305,32 @@ describe('Basket Selectors', () => { expect(getBasketValidationResults(store$.state).errors[0].lineItem.id).toEqual('4712'); }); }); + + describe('loading a submitted basket', () => { + beforeEach(() => { + store$.dispatch( + loadProductSuccess({ + product: { sku: 'sku', completenessLevel: ProductCompletenessLevel.Detail } as Product, + }) + ); + store$.dispatch( + loadBasketSuccess({ + basket: { + id: 'test', + lineItems: [{ id: 'test', productSKU: 'sku', quantity: { value: 5 } } as LineItem], + payment: { paymentInstrument: { id: 'ISH_INVOICE' } }, + } as BasketView, + }) + ); + }); + + it('should return the submitted basket when called', () => { + expect(getCurrentBasket(store$.state).id).toBe('test'); + expect(getSubmittedBasket(store$.state)).toBeUndefined(); + + store$.dispatch(submitBasketSuccess()); + expect(getSubmittedBasket(store$.state).id).toBe('test'); + expect(getCurrentBasket(store$.state)).toBeUndefined(); + }); + }); }); diff --git a/src/app/core/store/customer/basket/basket.selectors.ts b/src/app/core/store/customer/basket/basket.selectors.ts index 34ae16a927..bda825d315 100644 --- a/src/app/core/store/customer/basket/basket.selectors.ts +++ b/src/app/core/store/customer/basket/basket.selectors.ts @@ -42,6 +42,14 @@ export const getCurrentBasket = createSelector( (basket, validationResults, basketInfo): BasketView => createBasketView(basket.basket, validationResults, basketInfo) ); +export const getSubmittedBasket = createSelector( + getBasketState, + getBasketValidationResults, + getBasketInfo, + (basket, validationResults, basketInfo): BasketView => + createBasketView(basket.submittedBasket, validationResults, basketInfo) +); + export const getCurrentBasketId = createSelector(getBasketState, basket => basket.basket ? basket.basket.id : undefined ); diff --git a/src/app/core/store/customer/customer-store.module.ts b/src/app/core/store/customer/customer-store.module.ts index 33cff8cec6..415b0bee1e 100644 --- a/src/app/core/store/customer/customer-store.module.ts +++ b/src/app/core/store/customer/customer-store.module.ts @@ -20,6 +20,7 @@ import { CustomerState } from './customer-store'; import { OrdersEffects } from './orders/orders.effects'; import { ordersReducer } from './orders/orders.reducer'; import { OrganizationManagementEffects } from './organization-management/organization-management.effects'; +import { RequisitionManagementEffects } from './requisition-management/requisition-management.effects'; import { UserEffects } from './user/user.effects'; import { userReducer } from './user/user.reducer'; @@ -43,6 +44,7 @@ const customerEffects = [ UserEffects, AuthorizationEffects, OrganizationManagementEffects, + RequisitionManagementEffects, ]; const metaReducers = [resetOnLogoutMeta]; diff --git a/src/app/core/store/customer/orders/orders.effects.ts b/src/app/core/store/customer/orders/orders.effects.ts index 4a08b42f45..9b694ec6e2 100644 --- a/src/app/core/store/customer/orders/orders.effects.ts +++ b/src/app/core/store/customer/orders/orders.effects.ts @@ -186,7 +186,6 @@ export class OrdersEffects { withLatestFrom(this.store.pipe(select(getSelectedOrderId))), filter(([fromAction, selectedOrderId]) => fromAction && fromAction !== selectedOrderId), map(([orderId]) => orderId), - distinctUntilChanged(), map(orderId => selectOrder({ orderId })) ) ); diff --git a/src/app/core/store/customer/orders/orders.reducer.ts b/src/app/core/store/customer/orders/orders.reducer.ts index 73f42d100b..848b62ae72 100644 --- a/src/app/core/store/customer/orders/orders.reducer.ts +++ b/src/app/core/store/customer/orders/orders.reducer.ts @@ -3,7 +3,7 @@ import { createReducer, on } from '@ngrx/store'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { Order } from 'ish-core/models/order/order.model'; -import { setErrorOn, setLoadingOn } from 'ish-core/utils/ngrx-creators'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; import { createOrder, @@ -36,28 +36,26 @@ export const initialState: OrdersState = orderAdapter.getInitialState({ export const ordersReducer = createReducer( initialState, + setLoadingOn(createOrder, loadOrder, loadOrders), + unsetLoadingAndErrorOn(createOrderSuccess, loadOrderSuccess, loadOrdersSuccess), + setErrorOn(loadOrdersFail, loadOrderFail, createOrderFail), on(selectOrder, (state: OrdersState, action) => ({ ...state, selected: action.payload.orderId, })), - setLoadingOn(loadOrders, loadOrder, createOrder), + on(createOrderSuccess, loadOrderSuccess, (state: OrdersState, action) => { const { order } = action.payload; return { ...orderAdapter.upsertOne(order, state), selected: order.id, - loading: false, - error: undefined, }; }), on(loadOrdersSuccess, (state: OrdersState, action) => { const { orders } = action.payload; return { ...orderAdapter.setAll(orders, state), - loading: false, - error: undefined, }; - }), - setErrorOn(loadOrdersFail, loadOrderFail, createOrderFail) + }) ); diff --git a/src/app/core/store/customer/requisition-management/requisition-management.effects.ts b/src/app/core/store/customer/requisition-management/requisition-management.effects.ts new file mode 100644 index 0000000000..828b3e31f5 --- /dev/null +++ b/src/app/core/store/customer/requisition-management/requisition-management.effects.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { createEffect } from '@ngrx/effects'; +import { RequisitionManagementBreadcrumbService } from 'requisition-management'; +import { map } from 'rxjs/operators'; + +import { setBreadcrumbData } from 'ish-core/store/core/viewconf'; + +@Injectable() +export class RequisitionManagementEffects { + constructor(private requisitionManagementBreadcrumbService: RequisitionManagementBreadcrumbService) {} + + setRequisitionManagementBreadcrumb$ = createEffect(() => + this.requisitionManagementBreadcrumbService + .breadcrumb$('/account/requisitions') + .pipe(map(breadcrumbData => setBreadcrumbData({ breadcrumbData }))) + ); +} diff --git a/src/app/core/utils/dev/basket-mock-data.ts b/src/app/core/utils/dev/basket-mock-data.ts index 3d40ace941..5a64a7a2c9 100644 --- a/src/app/core/utils/dev/basket-mock-data.ts +++ b/src/app/core/utils/dev/basket-mock-data.ts @@ -43,6 +43,7 @@ export class BasketMockData { return { id: '4711', documentNo: '12345678', + customerNo: 'OilCorp', lineItems: [BasketMockData.getBasketItem()], invoiceToAddress: BasketMockData.getAddress(), commonShipToAddress: BasketMockData.getAddress(), diff --git a/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.html b/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.html index dcf33a22df..e7d341b764 100644 --- a/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.html +++ b/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.html @@ -1,22 +1,40 @@ -
-

{{ 'account.quotes.widget.my_quotes.heading' | translate }}

+ +
+
+
+
+
+ {{ respondedQuotes$ | async }} +
+
+ {{ 'account.quotes.widget.responded.label' | translate }} +
+
+
+
+ {{ submittedQuoteRequests$ | async }} +
+
+ {{ 'account.quotes.widget.submitted.label' | translate }} +
+
+
-
-
-
{{ 'account.quotes.widget.new.label' | translate }}
-
{{ counts['New'] || 0 }}
-
{{ 'account.quotes.widget.submitted.label' | translate }}
-
{{ counts['Submitted'] || 0 }}
-
{{ 'account.quotes.widget.accepted.label' | translate }}
-
{{ counts['Accepted'] || 0 }}
-
{{ 'account.quotes.widget.rejected.label' | translate }}
-
{{ counts['Rejected'] || 0 }}
+
- -
+ - {{ - 'account.quotes.widget.view_all_quotes.link' | translate - }} -
+ + + diff --git a/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.spec.ts b/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.spec.ts index 16fc55156c..66d69e3e2b 100644 --- a/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.spec.ts +++ b/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.spec.ts @@ -1,10 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TranslateModule } from '@ngx-translate/core'; import { range } from 'lodash-es'; import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; import { instance, mock, when } from 'ts-mockito'; +import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; import { QuotingFacade } from '../../facades/quoting.facade'; @@ -24,7 +25,7 @@ describe('Quote Widget Component', () => { await TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [MockComponent(LoadingComponent), QuoteWidgetComponent], + declarations: [MockComponent(InfoBoxComponent), MockComponent(LoadingComponent), QuoteWidgetComponent], providers: [{ provide: QuotingFacade, useFactory: () => instance(quotingFacade) }], }).compileComponents(); }); @@ -33,14 +34,6 @@ describe('Quote Widget Component', () => { fixture = TestBed.createComponent(QuoteWidgetComponent); component = fixture.componentInstance; element = fixture.nativeElement; - - const translate = TestBed.inject(TranslateService); - translate.setDefaultLang('en'); - translate.use('en'); - translate.set('account.quotes.widget.new.label', 'N'); - translate.set('account.quotes.widget.submitted.label', 'S'); - translate.set('account.quotes.widget.accepted.label', 'A'); - translate.set('account.quotes.widget.rejected.label', 'R'); }); it('should be created', () => { @@ -67,8 +60,9 @@ describe('Quote Widget Component', () => { fixture.detectChanges(); - const quoteWidget = element.querySelector('[data-testing-id="quote-widget"]'); - expect(quoteWidget).toBeTruthy(); - expect(quoteWidget.textContent).toMatchInlineSnapshot(`"N1S1A2R1"`); + const respondedCounter = element.querySelector('[data-testing-id="responded-counter"]'); + const submittedCounter = element.querySelector('[data-testing-id="submitted-counter"]'); + expect(respondedCounter.textContent.trim()).toEqual('1'); + expect(submittedCounter.textContent.trim()).toEqual('1'); }); }); diff --git a/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.ts b/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.ts index 559ebb3a47..5d239ef027 100644 --- a/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.ts +++ b/src/app/extensions/quoting/shared/quote-widget/quote-widget.component.ts @@ -1,14 +1,10 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { countBy } from 'lodash-es'; import { Observable, combineLatest, iif, of } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; import { QuotingFacade } from '../../facades/quoting.facade'; -import { QuoteStatus } from '../../models/quoting/quoting.model'; - -type DisplayState = 'New' | 'Submitted' | 'Accepted' | 'Rejected'; @Component({ selector: 'ish-quote-widget', @@ -19,29 +15,25 @@ type DisplayState = 'New' | 'Submitted' | 'Accepted' | 'Rejected'; export class QuoteWidgetComponent implements OnInit { loading$: Observable; - counts$: Observable>; + respondedQuotes$: Observable; + submittedQuoteRequests$: Observable; constructor(private quotingFacade: QuotingFacade) {} ngOnInit() { this.loading$ = this.quotingFacade.loading$; - this.counts$ = this.quotingFacade.quotingEntities$().pipe( - switchMap(quotes => - iif(() => !quotes?.length, of([]), combineLatest(quotes.map(quote => this.quotingFacade.state$(quote.id)))) - ), - map(quotes => countBy(quotes, quote => this.mapState(quote))) + const quotingStates$ = this.quotingFacade + .quotingEntities$() + .pipe( + switchMap(quotes => + iif(() => !quotes?.length, of([]), combineLatest(quotes.map(quote => this.quotingFacade.state$(quote.id)))) + ) + ); + + this.respondedQuotes$ = quotingStates$.pipe(map(states => states.filter(state => state === 'Responded').length)); + this.submittedQuoteRequests$ = quotingStates$.pipe( + map(states => states.filter(state => state === 'Submitted').length) ); } - - mapState(state: QuoteStatus): DisplayState { - switch (state) { - case 'Responded': - case 'Expired': - return 'Accepted'; - - default: - return state as DisplayState; - } - } } 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 0931cc56ad..5c945c64fc 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 @@ -1,6 +1,6 @@
@@ -63,9 +80,13 @@

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

> -
-

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

- +
+
+
+

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

+ +
+
diff --git a/src/app/pages/account-order/account-order/account-order.component.spec.ts b/src/app/pages/account-order/account-order/account-order.component.spec.ts index eea8da8dd5..2c50c7f6b2 100644 --- a/src/app/pages/account-order/account-order/account-order.component.spec.ts +++ b/src/app/pages/account-order/account-order/account-order.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateModule } from '@ngx-translate/core'; import { MockComponent, MockPipe } from 'ng-mocks'; @@ -28,7 +29,7 @@ describe('Account Order Component', () => { MockComponent(LineItemListComponent), MockPipe(DatePipe), ], - imports: [TranslateModule.forRoot()], + imports: [RouterTestingModule, TranslateModule.forRoot()], }).compileComponents(); }); diff --git a/src/app/pages/account-overview/account-overview-page.module.ts b/src/app/pages/account-overview/account-overview-page.module.ts index a56546ec5a..5873ddad56 100644 --- a/src/app/pages/account-overview/account-overview-page.module.ts +++ b/src/app/pages/account-overview/account-overview-page.module.ts @@ -1,4 +1,6 @@ import { NgModule } from '@angular/core'; +import { OrganizationManagementExportsModule } from 'organization-management'; +import { RequisitionManagementExportsModule } from 'requisition-management'; import { SharedModule } from 'ish-shared/shared.module'; @@ -6,7 +8,7 @@ import { AccountOverviewPageComponent } from './account-overview-page.component' import { AccountOverviewComponent } from './account-overview/account-overview.component'; @NgModule({ - imports: [SharedModule], + imports: [OrganizationManagementExportsModule, RequisitionManagementExportsModule, SharedModule], declarations: [AccountOverviewComponent, AccountOverviewPageComponent], }) export class AccountOverviewPageModule { 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 b178a03a3a..0d3392612a 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 @@ -6,41 +6,63 @@ " data-testing-id="personal-message-b2b" >

+ +

{{ 'account.overview.message_b2b.text' | translate }}

+

-
-

{{ 'account.overview.message.text' | translate }}

+

{{ 'account.overview.message.text' | translate }}

-