From 7b412b794295e3a43bfc81133a39e1e6a0dd6f07 Mon Sep 17 00:00:00 2001 From: Silke Date: Thu, 30 Sep 2021 14:54:18 +0200 Subject: [PATCH] feat: add cost center approval widget (#887) --- .../requisition/requisition.mapper.spec.ts | 12 ++ .../models/requisition/requisition.mapper.ts | 1 + .../models/requisition/requisition.model.ts | 10 +- ...sition-cost-center-approval.component.html | 96 ++++++++++++ ...ion-cost-center-approval.component.spec.ts | 144 ++++++++++++++++++ ...uisition-cost-center-approval.component.ts | 80 ++++++++++ .../requisition-detail-page.component.html | 2 + .../requisition-detail-page.component.spec.ts | 2 + .../requisition-detail-page.module.ts | 2 + .../requisitions/requisitions.service.spec.ts | 28 +++- .../requisitions/requisitions.service.ts | 46 +++++- .../basket-approval/basket-approval.model.ts | 4 +- .../models/cost-center/cost-center.model.ts | 18 +++ .../core/services/user/user.service.spec.ts | 9 ++ src/app/core/services/user/user.service.ts | 30 +++- .../account-navigation.component.ts | 4 +- .../basket-approval-info.component.html | 2 +- .../basket-approval-info.component.spec.ts | 2 +- src/assets/i18n/de_DE.json | 2 + src/assets/i18n/en_US.json | 2 + src/assets/i18n/fr_FR.json | 2 + 21 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html create mode 100644 projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.spec.ts create mode 100644 projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.ts create mode 100644 src/app/core/models/cost-center/cost-center.model.ts diff --git a/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts index c1b659f130..c93b5dfaa6 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts @@ -30,6 +30,11 @@ describe('Requisition Mapper', () => { costCenter: 'CostCenter123', creationDate: 12345678, lineItemCount: 2, + approval: { + costCenterApproval: { + approvers: [{ email: 'jlink@test.intershop.de' }], + }, + }, approvalStatus: { status: 'APPROVED', approver: { firstName: 'Bernhard', lastName: 'Boldner' }, @@ -55,6 +60,13 @@ describe('Requisition Mapper', () => { "firstName": "Bernhard", "lastName": "Boldner", }, + "costCenterApproval": Object { + "approvers": Array [ + Object { + "email": "jlink@test.intershop.de", + }, + ], + }, "customerApprovers": undefined, "status": "APPROVED", }, diff --git a/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts b/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts index 1a2164f779..6ea10b5bbb 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.ts @@ -31,6 +31,7 @@ export class RequisitionMapper { approval: { ...data.approvalStatus, customerApprovers: data.approval?.customerApproval?.approvers, + costCenterApproval: data.approval?.costCenterApproval, }, }; } else { diff --git a/projects/requisition-management/src/app/models/requisition/requisition.model.ts b/projects/requisition-management/src/app/models/requisition/requisition.model.ts index dc2af366db..9bff7c6dae 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.model.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.model.ts @@ -1,6 +1,8 @@ import { UserBudget } from 'organization-management'; +import { BasketApprover } from 'ish-core/models/basket-approval/basket-approval.model'; import { AbstractBasket } from 'ish-core/models/basket/basket.model'; +import { CostCenter } from 'ish-core/models/cost-center/cost-center.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'; @@ -15,7 +17,13 @@ export interface RequisitionApproval { approvalDate?: number; approver?: { firstName: string; lastName: string }; approvalComment?: string; - customerApprovers?: { firstName: string; lastName: string; email: string }[]; + customerApprovers?: BasketApprover[]; + costCenterApproval?: { + approvers?: BasketApprover[]; + costCenterName?: string; + costCenterID?: string; + costCenter?: CostCenter; + }; } export interface RequisitionUserBudget extends UserBudget { diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html new file mode 100644 index 0000000000..7abd95f091 --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.html @@ -0,0 +1,96 @@ + +
+ +
+
+
+
{{ 'approval.detailspage.cost_center.label' | translate }}
+
{{ costCenter.costCenterId }} {{ costCenter.name }}
+
+
+
+ +
+
+
+
+ {{ 'approval.detailspage.cost_center.label' | translate }} {{ ccVal.budgetLabel | translate }} +
+
+ + + {{ costCenter.budget | ishPrice }} + + + {{ 'account.budget.unlimited' | translate }} + + +
+
+
+
{{ 'account.budget.already_spent.label' | translate }}
+
+ {{ costCenter.spentBudget | ishPrice }} ({{ ccVal.spentPercentage | percent }}) +
+
+
+
{{ 'approval.detailspage.budget.including_order.label' | translate }}
+
+ {{ ccVal.spentBudgetIncludingThisRequisition | ishPrice }} ({{ + ccVal.spentPercentageIncludingThisRequisition | percent + }}) +
+
+
+
+ +
+
+ +
+
+
+
+ {{ 'approval.detailspage.buyer.label' | translate }} {{ bVal.budgetLabel | translate }} +
+
+ + + {{ buyer.budget | ishPrice }} + + + {{ 'account.budget.unlimited' | translate }} + + +
+
+
+
{{ 'account.budget.already_spent.label' | translate }}
+
+ {{ buyer.spentBudget | ishPrice }} ({{ bVal.spentPercentage | percent }}) +
+
+
+
{{ 'approval.detailspage.budget.including_order.label' | translate }}
+
+ {{ bVal.spentBudgetIncludingThisRequisition | ishPrice }} ({{ + bVal.spentPercentageIncludingThisRequisition | percent + }}) +
+
+
+
+ +
+
+
+
+
diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.spec.ts b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.spec.ts new file mode 100644 index 0000000000..606e447e60 --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.spec.ts @@ -0,0 +1,144 @@ +import { CompilerOptions } from '@angular/core'; +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 { CostCenter, CostCenterBuyer } from 'ish-core/models/cost-center/cost-center.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 { RequisitionCostCenterApprovalComponent } from './requisition-cost-center-approval.component'; + +describe('Requisition Cost Center Approval Component', () => { + let component: RequisitionCostCenterApprovalComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let accountFacade: AccountFacade; + + beforeEach(async () => { + accountFacade = mock(AccountFacade); + when(accountFacade.userPriceDisplayType$).thenReturn(of('gross')); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ + MockComponent(BudgetBarComponent), + MockComponent(InfoBoxComponent), + PricePipe, + RequisitionCostCenterApprovalComponent, + ], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], + }) + .configureCompiler({ preserveWhitespaces: true } as CompilerOptions) + .compileComponents(); + + when(accountFacade.userEmail$).thenReturn(of('jlink@test.intershop.de')); + }); + + beforeEach(() => { + const translate = TestBed.inject(TranslateService); + translate.use('en'); + + fixture = TestBed.createComponent(RequisitionCostCenterApprovalComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + component.requisition = { + id: 'testUUDI', + requisitionNo: '0001', + orderNo: '10001', + approval: { + statusCode: 'APPROVED', + approver: { firstName: 'Bernhard', lastName: 'Boldner' }, + approvalDate: 76543627, + costCenterApproval: { + approvers: [{ email: 'jlink@test.intershop.de' }], + costCenter: { + costCenterId: '100450', + name: 'Headquarter', + budgetPeriod: 'weekly', + budget: { currency: 'USD', value: 3000, type: 'Money' }, + spentBudget: { currency: 'USD', value: 300, type: 'Money' }, + } as CostCenter, + }, + }, + user: { firstName: 'Patricia', lastName: 'Miller', email: 'pmiller@test.intershop.de' }, + 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 infobox if the cost center data are given', () => { + component.ngOnChanges(); + fixture.detectChanges(); + expect(element.querySelector('ish-info-box')).toBeTruthy(); + }); + + it('should not display infobox if there are no cost center data', () => { + component.requisition.approval.costCenterApproval.costCenter = undefined; + component.ngOnChanges(); + fixture.detectChanges(); + expect(element.querySelector('ish-info-box')).toBeFalsy(); + }); + + it('should not display infobox if the current user is not the approver of the cost center', () => { + when(accountFacade.userEmail$).thenReturn(of('bboldner@test.intershop.de')); + + component.ngOnChanges(); + fixture.detectChanges(); + expect(element.querySelector('ish-info-box')).toBeFalsy(); + }); + + it('should display cost center budget information if created', () => { + component.ngOnChanges(); + fixture.detectChanges(); + + expect(element.textContent.replace(/^\s*[\r\n]*/gm, '')).toMatchInlineSnapshot(` +"approval.detailspage.cost_center.label +100450 Headquarter +approval.detailspage.cost_center.label account.budget.type.weekly.label +$3,000.00 +account.budget.already_spent.label +$300.00 (10%) +" +`); + }); + + it('should display cost center buyer budget information if there are buyer data', () => { + component.requisition.approval.costCenterApproval.costCenter.buyers = [ + { + email: 'pmiller@test.intershop.de', + budgetPeriod: 'monthly', + budget: { currency: 'USD', value: 4000, type: 'Money' }, + spentBudget: { currency: 'USD', value: 200, type: 'Money' }, + } as CostCenterBuyer, + ]; + + component.ngOnChanges(); + fixture.detectChanges(); + + expect( + element.querySelector('[data-testing-id="cost-center-buyer-budget"]').textContent.replace(/^\s*[\r\n]*/gm, '') + ).toMatchInlineSnapshot(` + "approval.detailspage.buyer.label account.budget.type.monthly.label + $4,000.00 + account.budget.already_spent.label + $200.00 (5%) + " + `); + }); +}); diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.ts b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.ts new file mode 100644 index 0000000000..b7659d1bcf --- /dev/null +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-cost-center-approval/requisition-cost-center-approval.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { CostCenter, CostCenterBuyer } from 'ish-core/models/cost-center/cost-center.model'; +import { Price, PriceHelper } from 'ish-core/models/price/price.model'; + +import { Requisition } from '../../../models/requisition/requisition.model'; + +interface BudgetValues { + budgetLabel: string; + spentPercentage: number; + spentBudgetIncludingThisRequisition: Price; + spentPercentageIncludingThisRequisition: number; +} + +@Component({ + selector: 'ish-requisition-cost-center-approval', + templateUrl: './requisition-cost-center-approval.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RequisitionCostCenterApprovalComponent implements OnInit, OnChanges { + @Input() requisition: Requisition; + + costCenter: CostCenter; + approverEmail: string; + orderTotal: Price; + buyer: CostCenterBuyer; + + ccVal: BudgetValues; + bVal: BudgetValues; // buyer budget values + + userEmail$: Observable; + + constructor(private accountFacade: AccountFacade) {} + + ngOnInit() { + this.userEmail$ = this.accountFacade.userEmail$; + } + + ngOnChanges() { + this.costCenter = this.requisition?.approval?.costCenterApproval?.costCenter; + + this.approverEmail = this.requisition?.approval?.costCenterApproval?.approvers?.length + ? this.requisition.approval.costCenterApproval.approvers[0].email + : undefined; + + this.orderTotal = { + type: 'Money', + value: this.requisition?.totals?.total?.gross, + currency: this.requisition?.totals?.total?.currency, + }; + + if (this.costCenter) { + this.ccVal = this.determineBudgetValues(this.costCenter); + + this.buyer = this.costCenter.buyers?.find(buyer => buyer.email === this.requisition?.user.email); + this.bVal = this.determineBudgetValues(this.buyer); + } + } + + /** calculates all displayed prices and percentages for a budget related object */ + private determineBudgetValues(data: CostCenter | CostCenterBuyer): BudgetValues { + if (!data) { + return; + } + + const spentBudgetIncludingThisRequisition = PriceHelper.sum(data?.spentBudget, this.orderTotal); + + return { + budgetLabel: `account.budget.type.${data.budgetPeriod}.label`, + spentPercentage: data?.spentBudget?.value ? data.spentBudget?.value / data.budget.value : 0, + spentBudgetIncludingThisRequisition, + spentPercentageIncludingThisRequisition: + spentBudgetIncludingThisRequisition?.value && data?.budget?.value + ? spentBudgetIncludingThisRequisition.value / data?.budget?.value + : 0, + }; + } +} 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 index 4729c0c1e7..02d9a149f7 100644 --- 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 @@ -27,6 +27,8 @@

+ +

{{ 'approval.detailspage.order_details.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 index 211ab9bc77..9122d69a18 100644 --- 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 @@ -16,6 +16,7 @@ import { LineItemListComponent } from 'ish-shared/components/line-item/line-item import { RequisitionContextFacade } from '../../facades/requisition-context.facade'; import { RequisitionBuyerApprovalComponent } from './requisition-buyer-approval/requisition-buyer-approval.component'; +import { RequisitionCostCenterApprovalComponent } from './requisition-cost-center-approval/requisition-cost-center-approval.component'; import { RequisitionDetailPageComponent } from './requisition-detail-page.component'; import { RequisitionRejectDialogComponent } from './requisition-reject-dialog/requisition-reject-dialog.component'; import { RequisitionSummaryComponent } from './requisition-summary/requisition-summary.component'; @@ -40,6 +41,7 @@ describe('Requisition Detail Page Component', () => { MockComponent(LineItemListComponent), MockComponent(LoadingComponent), MockComponent(RequisitionBuyerApprovalComponent), + MockComponent(RequisitionCostCenterApprovalComponent), MockComponent(RequisitionRejectDialogComponent), MockComponent(RequisitionSummaryComponent), RequisitionDetailPageComponent, diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.module.ts b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.module.ts index ecd10668a4..cac45f58eb 100644 --- a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.module.ts +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.module.ts @@ -7,6 +7,7 @@ import { RequisitionManagementModule } from '../../requisition-management.module import { BudgetBarComponent } from './budget-bar/budget-bar.component'; import { RequisitionBuyerApprovalComponent } from './requisition-buyer-approval/requisition-buyer-approval.component'; +import { RequisitionCostCenterApprovalComponent } from './requisition-cost-center-approval/requisition-cost-center-approval.component'; import { RequisitionDetailPageComponent } from './requisition-detail-page.component'; import { RequisitionRejectDialogComponent } from './requisition-reject-dialog/requisition-reject-dialog.component'; import { RequisitionSummaryComponent } from './requisition-summary/requisition-summary.component'; @@ -18,6 +19,7 @@ const requisitionDetailPageRoutes: Routes = [{ path: '', component: RequisitionD declarations: [ BudgetBarComponent, RequisitionBuyerApprovalComponent, + RequisitionCostCenterApprovalComponent, RequisitionDetailPageComponent, RequisitionRejectDialogComponent, RequisitionSummaryComponent, 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 index 8295467001..9c5d3c8d91 100644 --- a/projects/requisition-management/src/app/services/requisitions/requisitions.service.spec.ts +++ b/projects/requisition-management/src/app/services/requisitions/requisitions.service.spec.ts @@ -2,24 +2,34 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { CostCenter } from 'ish-core/models/cost-center/cost-center.model'; import { ApiService } from 'ish-core/services/api/api.service'; +import { UserService } from 'ish-core/services/user/user.service'; + +import { RequisitionBaseData } from '../../models/requisition/requisition.interface'; import { RequisitionsService } from './requisitions.service'; describe('Requisitions Service', () => { let apiServiceMock: ApiService; + let userServiceMock: UserService; let requisitionsService: RequisitionsService; beforeEach(() => { apiServiceMock = mock(ApiService); + userServiceMock = mock(UserService); when(apiServiceMock.b2bUserEndpoint()).thenReturn(instance(apiServiceMock)); TestBed.configureTestingModule({ - providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }], + providers: [ + { provide: ApiService, useFactory: () => instance(apiServiceMock) }, + { provide: UserService, useFactory: () => instance(userServiceMock) }, + ], }); requisitionsService = TestBed.inject(RequisitionsService); when(apiServiceMock.get(anything(), anything())).thenReturn(of({ data: {} })); when(apiServiceMock.patch(anything(), anything(), anything())).thenReturn(of({ data: {} })); + when(userServiceMock.getCostCenter(anything())).thenReturn(of({} as CostCenter)); }); it('should be created', () => { @@ -40,6 +50,22 @@ describe('Requisitions Service', () => { }); }); + it('should call getCostCenter when fetching a requisition with cost center approval', done => { + when(apiServiceMock.get(anything(), anything())).thenReturn( + of({ + data: { + requisitionNo: '4712', + attributes: [{ name: 'BusinessObjectAttributes#Order_CostCenter', value: '123456' }], + approval: { costCenterApproval: { costCenterID: '100543' } }, + } as RequisitionBaseData, + }) + ); + requisitionsService.getRequisition('4712').subscribe(() => { + verify(userServiceMock.getCostCenter(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(); diff --git a/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts b/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts index 9ad8b23a5f..d21be0f299 100644 --- a/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts +++ b/projects/requisition-management/src/app/services/requisitions/requisitions.service.ts @@ -3,8 +3,10 @@ import { Injectable } from '@angular/core'; import { Observable, of, throwError } from 'rxjs'; import { concatMap, map } from 'rxjs/operators'; +import { AttributeHelper } from 'ish-core/models/attribute/attribute.helper'; import { OrderData } from 'ish-core/models/order/order.interface'; import { ApiService } from 'ish-core/services/api/api.service'; +import { UserService } from 'ish-core/services/user/user.service'; import { RequisitionData } from '../../models/requisition/requisition.interface'; import { RequisitionMapper } from '../../models/requisition/requisition.mapper'; @@ -23,7 +25,11 @@ type RequisitionIncludeType = @Injectable({ providedIn: 'root' }) export class RequisitionsService { - constructor(private apiService: ApiService, private requisitionMapper: RequisitionMapper) {} + constructor( + private apiService: ApiService, + private userService: UserService, + private requisitionMapper: RequisitionMapper + ) {} private allIncludes: RequisitionIncludeType[] = [ 'invoiceToAddress', @@ -80,7 +86,13 @@ export class RequisitionsService { .get(`requisitions/${requisitionId}`, { params, }) - .pipe(concatMap(payload => this.processRequisitionData(payload))); + .pipe( + concatMap(payload => + this.processRequisitionData(payload).pipe( + concatMap(requisition => this.getRequisitionCostCenter(requisition)) + ) + ) + ); } /** @@ -141,4 +153,34 @@ export class RequisitionsService { return of(this.requisitionMapper.fromData(payload)); } + + /** + * Gets the cost center data, if cost center approval is needed (the current user needs cost center admin permission) and appends it at the requisition cost center approval data. + * @param requisition The requisition (without cost center). + * @returns The requisition with cost center if appropriate(). + */ + private getRequisitionCostCenter(requisition: Requisition): Observable { + if (!requisition) { + return of(undefined); + } + + const costCenterUuid = AttributeHelper.getAttributeValueByAttributeName( + requisition.attributes, + 'BusinessObjectAttributes#Order_CostCenter' + ) as string; + + if (!requisition.approval?.costCenterApproval || !costCenterUuid) { + return of(requisition); + } else { + return this.userService.getCostCenter(costCenterUuid).pipe( + map(costCenter => ({ + ...requisition, + approval: { + ...requisition.approval, + costCenterApproval: { ...requisition.approval.costCenterApproval, costCenter }, + }, + })) + ); + } + } } diff --git a/src/app/core/models/basket-approval/basket-approval.model.ts b/src/app/core/models/basket-approval/basket-approval.model.ts index 5ceb942346..0459a22fe1 100644 --- a/src/app/core/models/basket-approval/basket-approval.model.ts +++ b/src/app/core/models/basket-approval/basket-approval.model.ts @@ -1,4 +1,4 @@ -interface BasketApprover { +export interface BasketApprover { email: string; firstName: string; lastName: string; @@ -12,7 +12,7 @@ export interface BasketApproval { }; costCenterApproval?: { approvers?: BasketApprover[]; - costCenterId: string; + costCenterID: string; costCenterName?: string; }; } diff --git a/src/app/core/models/cost-center/cost-center.model.ts b/src/app/core/models/cost-center/cost-center.model.ts new file mode 100644 index 0000000000..e534c26a20 --- /dev/null +++ b/src/app/core/models/cost-center/cost-center.model.ts @@ -0,0 +1,18 @@ +import { BasketApprover } from 'ish-core/models/basket-approval/basket-approval.model'; +import { Price } from 'ish-core/models/price/price.model'; + +export interface CostCenterBuyer extends BasketApprover { + budget: Price; + budgetPeriod: string; + spentBudget?: Price; +} + +export interface CostCenter { + id: string; // uuid + costCenterId: string; + name: string; + budget: Price; + budgetPeriod: string; + spentBudget?: Price; + buyers?: CostCenterBuyer[]; +} diff --git a/src/app/core/services/user/user.service.spec.ts b/src/app/core/services/user/user.service.spec.ts index 88f6dc329d..4b403f9165 100644 --- a/src/app/core/services/user/user.service.spec.ts +++ b/src/app/core/services/user/user.service.spec.ts @@ -11,6 +11,7 @@ import { CustomerData } from 'ish-core/models/customer/customer.interface'; import { Customer, CustomerRegistrationType, CustomerUserType } from 'ish-core/models/customer/customer.model'; import { User } from 'ish-core/models/user/user.model'; import { ApiService, AvailableOptions } from 'ish-core/services/api/api.service'; +import { getUserPermissions } from 'ish-core/store/customer/authorization'; import { getLoggedInCustomer, getLoggedInUser } from 'ish-core/store/customer/user'; import { UserService } from './user.service'; @@ -313,6 +314,7 @@ describe('User Service', () => { beforeEach(() => { store$.overrideSelector(getLoggedInUser, user); store$.overrideSelector(getLoggedInCustomer, customer); + store$.overrideSelector(getUserPermissions, ['APP_B2B_VIEW_COSTCENTER']); when(apiServiceMock.get(anything())).thenReturn(of({})); }); @@ -323,5 +325,12 @@ describe('User Service', () => { done(); }); }); + + it("should get a cost center when 'getCostCenter' is called by a cost center admin", done => { + userService.getCostCenter('12345').subscribe(() => { + verify(apiServiceMock.get(`customers/${customer.customerNo}/costcenters/12345`)).once(); + done(); + }); + }); }); }); diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index 211432f4ec..1ea4afc599 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -7,6 +7,7 @@ import { concatMap, first, map, switchMap, take, withLatestFrom } from 'rxjs/ope import { AppFacade } from 'ish-core/facades/app.facade'; import { Address } from 'ish-core/models/address/address.model'; +import { CostCenter } from 'ish-core/models/cost-center/cost-center.model'; import { Credentials } from 'ish-core/models/credentials/credentials.model'; import { CustomerData, CustomerType } from 'ish-core/models/customer/customer.interface'; import { CustomerMapper } from 'ish-core/models/customer/customer.mapper'; @@ -17,6 +18,7 @@ import { UserCostCenter } from 'ish-core/models/user-cost-center/user-cost-cente import { UserMapper } from 'ish-core/models/user/user.mapper'; import { User } from 'ish-core/models/user/user.model'; import { ApiService, AvailableOptions, unpackEnvelope } from 'ish-core/services/api/api.service'; +import { getUserPermissions } from 'ish-core/store/customer/authorization'; import { getLoggedInCustomer, getLoggedInUser } from 'ish-core/store/customer/user'; import { whenTruthy } from 'ish-core/utils/operators'; @@ -282,8 +284,8 @@ export class UserService { } /** - * Get cost center data for the logged in User of a Business Customer. - * @returns The related cost center + * Get cost centers for the logged in User of a Business Customer. + * @returns The related cost centers. */ getEligibleCostCenters(): Observable { return combineLatest([ @@ -301,4 +303,28 @@ export class UserService { ) ); } + + /** + * Get cost center data of a business customer for a given cost center uuid. The logged in user needs permission APP_B2B_VIEW_COSTCENTER. + * @returns The related cost center. + */ + getCostCenter(id: string): Observable { + if (!id) { + return throwError('getCostCenter() called without uuid'); + } + + return combineLatest([ + this.store.pipe(select(getLoggedInCustomer), whenTruthy()), + this.store.pipe(select(getUserPermissions), whenTruthy()), + ]).pipe( + take(1), + switchMap(([customer, permissions]) => { + if (permissions.includes('APP_B2B_VIEW_COSTCENTER')) { + return this.apiService.get(`customers/${customer.customerNo}/costcenters/${id}`); + } else { + return of(undefined); + } + }) + ); + } } diff --git a/src/app/pages/account/account-navigation/account-navigation.component.ts b/src/app/pages/account/account-navigation/account-navigation.component.ts index d924b3b48f..aa4c8a31ae 100644 --- a/src/app/pages/account/account-navigation/account-navigation.component.ts +++ b/src/app/pages/account/account-navigation/account-navigation.component.ts @@ -9,7 +9,7 @@ interface NavigationItems { dataTestingId?: string; feature?: string; serverSetting?: string; - permission?: string; + permission?: string | string[]; notRole?: string | string[]; children?: NavigationItems; }; @@ -39,7 +39,7 @@ export class AccountNavigationComponent implements OnInit, OnChanges { '/account/requisitions/approver': { localizationKey: 'account.requisitions.approvals', serverSetting: 'services.OrderApprovalServiceDefinition.runnable', - permission: 'APP_B2B_ORDER_APPROVAL', + permission: ['APP_B2B_ORDER_APPROVAL', 'APP_B2B_MANAGE_COSTCENTER'], }, '/account/quotes': { localizationKey: 'account.navigation.quotes.link', diff --git a/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html b/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html index 25d0579bda..3d4e45223f 100644 --- a/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html +++ b/src/app/shared/components/basket/basket-approval-info/basket-approval-info.component.html @@ -48,7 +48,7 @@