Skip to content

Commit

Permalink
feat: add cost center approval widget (#887)
Browse files Browse the repository at this point in the history
  • Loading branch information
SGrueber committed Oct 22, 2021
1 parent 9ea04b8 commit 7b412b7
Show file tree
Hide file tree
Showing 21 changed files with 486 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class RequisitionMapper {
approval: {
...data.approvalStatus,
customerApprovers: data.approval?.customerApproval?.approvers,
costCenterApproval: data.approval?.costCenterApproval,
},
};
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<ng-container *ngIf="approverEmail === (userEmail$ | async)">
<div *ngIf="costCenter" class="row">
<ish-info-box heading="approval.detailspage.costcenter.approval.heading" class="infobox-wrapper col-12">
<div class="row">
<div class="col-md-6">
<dl class="row dl-horizontal dl-separator">
<dt class="col-6">{{ 'approval.detailspage.cost_center.label' | translate }}</dt>
<dd class="col-6">{{ costCenter.costCenterId }} {{ costCenter.name }}</dd>
</dl>
</div>
</div>

<div class="row section">
<div class="col-md-6 pt-1">
<dl class="row dl-horizontal dl-separator">
<dt class="col-6">
{{ 'approval.detailspage.cost_center.label' | translate }} {{ ccVal.budgetLabel | translate }}
</dt>
<dd class="col-6">
<strong>
<ng-container *ngIf="costCenter?.budget; else noLimit">
{{ costCenter.budget | ishPrice }}
</ng-container>
<ng-template #noLimit>
{{ 'account.budget.unlimited' | translate }}
</ng-template>
</strong>
</dd>
</dl>
<dl *ngIf="costCenter.spentBudget" class="row dl-horizontal dl-separator">
<dt class="col-6">{{ 'account.budget.already_spent.label' | translate }}</dt>
<dd class="col-6" [ngClass]="{ 'text-danger': ccVal.spentPercentage > 1 }">
{{ costCenter.spentBudget | ishPrice }} ({{ ccVal.spentPercentage | percent }})
</dd>
</dl>
<dl *ngIf="requisition.approval.statusCode !== 'APPROVED'" class="row dl-horizontal dl-separator">
<dt class="col-6">{{ 'approval.detailspage.budget.including_order.label' | translate }}</dt>
<dd class="col-6" [ngClass]="{ 'text-danger': ccVal.spentPercentageIncludingThisRequisition > 1 }">
{{ ccVal.spentBudgetIncludingThisRequisition | ishPrice }} ({{
ccVal.spentPercentageIncludingThisRequisition | percent
}})
</dd>
</dl>
</div>
<div *ngIf="costCenter.budget" class="col-md-6 mb-2">
<ish-budget-bar
[budget]="costCenter.budget"
[spentBudget]="costCenter.spentBudget"
[additionalAmount]="orderTotal"
></ish-budget-bar>
</div>
</div>

<div *ngIf="buyer" class="row" data-testing-id="cost-center-buyer-budget">
<div class="col-md-6">
<dl class="row dl-horizontal dl-separator">
<dt class="col-6">
{{ 'approval.detailspage.buyer.label' | translate }} {{ bVal.budgetLabel | translate }}
</dt>
<dd class="col-6">
<strong>
<ng-container *ngIf="buyer?.budget; else noLimit">
{{ buyer.budget | ishPrice }}
</ng-container>
<ng-template #noLimit>
{{ 'account.budget.unlimited' | translate }}
</ng-template>
</strong>
</dd>
</dl>
<dl *ngIf="buyer.spentBudget" class="row dl-horizontal dl-separator">
<dt class="col-6">{{ 'account.budget.already_spent.label' | translate }}</dt>
<dd class="col-6" [ngClass]="{ 'text-danger': bVal.spentPercentage > 1 }">
{{ buyer.spentBudget | ishPrice }} ({{ bVal.spentPercentage | percent }})
</dd>
</dl>
<dl *ngIf="requisition.approval.statusCode !== 'APPROVED'" class="row dl-horizontal dl-separator">
<dt class="col-6">{{ 'approval.detailspage.budget.including_order.label' | translate }}</dt>
<dd class="col-6" [ngClass]="{ 'text-danger': bVal.spentPercentageIncludingThisRequisition > 1 }">
{{ bVal.spentBudgetIncludingThisRequisition | ishPrice }} ({{
bVal.spentPercentageIncludingThisRequisition | percent
}})
</dd>
</dl>
</div>
<div *ngIf="buyer.budget" class="col-md-6 mb-2">
<ish-budget-bar
[budget]="buyer.budget"
[spentBudget]="buyer.spentBudget"
[additionalAmount]="orderTotal"
></ish-budget-bar>
</div>
</div>
</ish-info-box>
</div>
</ng-container>
Original file line number Diff line number Diff line change
@@ -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<RequisitionCostCenterApprovalComponent>;
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%)
"
`);
});
});
Original file line number Diff line number Diff line change
@@ -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<string>;

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,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ <h1>

<ish-requisition-buyer-approval [requisition]="requisition"></ish-requisition-buyer-approval>

<ish-requisition-cost-center-approval [requisition]="requisition"></ish-requisition-cost-center-approval>

<ng-container *ngTemplateOutlet="approvalButtonBar; context: { requisition: requisition }"></ng-container>

<h2>{{ 'approval.detailspage.order_details.heading' | translate }}</h2>
Expand Down
Loading

0 comments on commit 7b412b7

Please sign in to comment.