From 47818d00d82db9e7b60f9af8368fe0e912237b7e Mon Sep 17 00:00:00 2001 From: heslinge Date: Wed, 28 Jan 2026 12:39:08 -0700 Subject: [PATCH 1/6] add goal and cycle selector --- src/@seed/api/goal/goal.service.ts | 75 +++++++++++++ src/@seed/api/goal/goal.types.ts | 67 +++++++++++ src/@seed/api/goal/index.ts | 2 + .../add-cycle-dialog.component.html | 5 + .../add-cycle-dialog.component.ts | 42 +++++++ .../add-cycle-dialog/index.ts | 1 + .../configure-goals-dialog.component.html | 5 + .../configure-goals-dialog.component.ts | 42 +++++++ .../configure-goals-dialog/index.ts | 1 + .../insights/portfolio-summary/index.ts | 1 + .../portfolio-summary.component.html | 68 ++++++++++- .../portfolio-summary.component.ts | 106 +++++++++++++++++- .../portfolio-summary.types.ts | 3 + 13 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 src/@seed/api/goal/goal.service.ts create mode 100644 src/@seed/api/goal/goal.types.ts create mode 100644 src/@seed/api/goal/index.ts create mode 100644 src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html create mode 100644 src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts create mode 100644 src/app/modules/insights/portfolio-summary/add-cycle-dialog/index.ts create mode 100644 src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html create mode 100644 src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts create mode 100644 src/app/modules/insights/portfolio-summary/configure-goals-dialog/index.ts create mode 100644 src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts diff --git a/src/@seed/api/goal/goal.service.ts b/src/@seed/api/goal/goal.service.ts new file mode 100644 index 00000000..480b1589 --- /dev/null +++ b/src/@seed/api/goal/goal.service.ts @@ -0,0 +1,75 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { BehaviorSubject, catchError, map, take, tap } from 'rxjs' +import { OrganizationService } from '@seed/api/organization' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { Goal, GoalsResponse, PortfolioSummary } from './goal.types' + +@Injectable({ providedIn: 'root' }) +export class GoalService { + private _httpClient = inject(HttpClient) + private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) + private _errorService = inject(ErrorService) + private _goals = new BehaviorSubject([]) + private _portfolioSummary = new BehaviorSubject(undefined) + orgId: number + + goals$ = this._goals.asObservable() + + constructor() { + this._organizationService.currentOrganization$ + .pipe( + tap(({ org_id }) => { + this.get(org_id) + }), + ) + .subscribe() + } + + get(orgId: number) { + const url = `/api/v3/goals/?organization_id=${orgId}` + this._httpClient + .get(url) + .pipe( + take(1), + map(({ goals }) => goals), + tap((goals) => { + this._goals.next(goals) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching goals') + }), + ) + .subscribe() + } + + portfolioSummary(goalId: number, cycleGoalId: number, orgId: number) { + const url = `/api/v3/goals/${goalId}/cycles/${cycleGoalId}/?organization_id=${orgId}` + this._httpClient + .get(url) + .pipe( + take(1), + map((portfolioSummary) => portfolioSummary), + tap((portfolioSummary) => { + this._portfolioSummary.next(portfolioSummary) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching goals') + }), + ) + .subscribe() + } + + getPortfolioSummary(goalId: number, cycleGoalId: number, orgId: number): Observable { + const url = `/api/v3/goals/${goalId}/cycles/${cycleGoalId}/portfolio_summary?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } +} diff --git a/src/@seed/api/goal/goal.types.ts b/src/@seed/api/goal/goal.types.ts new file mode 100644 index 00000000..9049b60d --- /dev/null +++ b/src/@seed/api/goal/goal.types.ts @@ -0,0 +1,67 @@ +export type CycleGoal = { + id: number; + salesforce_annual_report_id?: string; + salesforce_annual_report_name?: string; + current_cycle: { + end: string; + start: string; + name: string; + }; +} + +export type Goal = { + access_level_instance: number; + area_column: number; + area_column_name: string; + baseline_cycle: number; + baseline_cycle_name: string; + commitment_sqft: number; + eui_column1: number; + eui_column1_name: string; + eui_column2?: string; + eui_column2_name?: string; + eui_column3?: string; + eui_column3_name?: string; + id: number; + level_name: string; + level_name_index: number; + name: string; + organization: number; + partner_note: string; + partner_note_approval: boolean; + partner_note_approval_time?: string; + partner_note_approval_user?: string; + salesforce_goal_id?: string; + salesforce_goal_name?: string; + salesforce_partner_id?: string; + salesforce_partner_name?: string; + target_percentage: number; + transactions_column?: string; + type: string; + access_level_instance_name: string; + cycle_goals: CycleGoal[]; +} + +export type GoalsResponse = { + status: string; + goals: Goal[]; +} + +export type PortfolioSummary = { + baseline_cycle_name: string; + baseline_total_sqft: number; + baseline_total_kbtu: number; + baseline_weighted_eui: number; + total_properties: number; + shared_sqft: number; + total_passing: number; + total_new_or_acquired: number; + passing_committed: number; + passing_shared: number; + current_cycle_name: string; + current_total_sqft: number; + current_total_kbtu: string; + current_weighted_eui: number; + sqft_change: number; + eui_change: number; +} diff --git a/src/@seed/api/goal/index.ts b/src/@seed/api/goal/index.ts new file mode 100644 index 00000000..b01be460 --- /dev/null +++ b/src/@seed/api/goal/index.ts @@ -0,0 +1,2 @@ +export * from './goal.service' +export * from './goal.types' diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html new file mode 100644 index 00000000..c7ad10c1 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts new file mode 100644 index 00000000..e365f600 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, ViewEncapsulation } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatDialogModule } from '@angular/material/dialog' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { Subject } from 'rxjs' +import { SharedImports } from '@seed/directives' + +@Component({ + selector: 'seed-add-cycle-dialog', + templateUrl: './add-cycle-dialog.component.html', + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ReactiveFormsModule, + SharedImports, + ], +}) +export class AddCycleDialogComponent implements OnInit, OnDestroy { + private readonly _unsubscribeAll$ = new Subject() + + ngOnInit(): void { + console.log('AddCycleDialogComponent') + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/index.ts b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/index.ts new file mode 100644 index 00000000..72e60f65 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/index.ts @@ -0,0 +1 @@ +export * from './add-cycle-dialog.component' diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html new file mode 100644 index 00000000..c7ad10c1 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts new file mode 100644 index 00000000..bc57ba30 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, ViewEncapsulation } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatDialogModule } from '@angular/material/dialog' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { Subject } from 'rxjs' +import { SharedImports } from '@seed/directives' + +@Component({ + selector: 'seed-configure-goals-dialog', + templateUrl: './configure-goals-dialog.component.html', + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ReactiveFormsModule, + SharedImports, + ], +}) +export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { + private readonly _unsubscribeAll$ = new Subject() + + ngOnInit(): void { + console.log('ConfigureGoalsDialogComponent') + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/index.ts b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/index.ts new file mode 100644 index 00000000..c207d8a8 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/index.ts @@ -0,0 +1 @@ +export * from './configure-goals-dialog.component' diff --git a/src/app/modules/insights/portfolio-summary/index.ts b/src/app/modules/insights/portfolio-summary/index.ts index 685c3f87..ce33728e 100644 --- a/src/app/modules/insights/portfolio-summary/index.ts +++ b/src/app/modules/insights/portfolio-summary/index.ts @@ -1 +1,2 @@ export * from './portfolio-summary.component' +export * from './portfolio-summary.types' diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html index 39945d64..d8c1e492 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html @@ -1,3 +1,67 @@ - -
Portfolio Summary Content
+ + +
+ +
+ + +
+ Goal: + + @for (goal of goals; track goal.id) { + {{ goal.name }} + } + +
+ + @if (currentGoal) { + +
+
Type: {{ currentGoal.type }}
+
Baseline Cycle: {{ currentGoal.baseline_cycle_name }}
+
{{ currentGoal.level_name }}: {{ currentGoal.access_level_instance_name }}
+
Portfolio Target: {{ currentGoal.target_percentage }}
+
Commitment Sq. Ft: {{ currentGoal.commitment_sqft || 'n/a' }}
+
Area Column: {{ currentGoal.area_column_name }}
+
Primary EUI: {{ currentGoal.eui_column1_name }}
+
+ + + + @for (cycle_goal of currentGoal.cycle_goals; track cycle_goal.id) { + {{ cycle_goal.current_cycle.name }} + } + + + + @if (currentCycleGoal) { + +
+ +
+ } + }
diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts index 13c9a61d..43467550 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts @@ -1,14 +1,112 @@ +import { CommonModule } from '@angular/common' import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' -import { PageComponent } from '@seed/components' +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import type { MatButtonToggleChange } from '@angular/material/button-toggle' +import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MatDialog } from '@angular/material/dialog' +import { MatIconModule } from '@angular/material/icon' +import type { MatSelectChange } from '@angular/material/select' +import { MatSelectModule } from '@angular/material/select' +import { AgGridAngular, AgGridModule } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { Subject, takeUntil } from 'rxjs' +import type { CycleGoal, Goal } from '@seed/api/goal' +import { GoalService } from '@seed/api/goal' +import type { Organization } from '@seed/api/organization' +import { OrganizationService } from '@seed/api/organization' +import { NotFoundComponent, PageComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { ConfigService } from '@seed/services' +import { AddCycleDialogComponent } from './add-cycle-dialog' +import { ConfigureGoalsDialogComponent } from './configure-goals-dialog' +import type { AddCycleData, ConfigureGoalsData } from './portfolio-summary.types' @Component({ selector: 'seed-portfolio-summary', templateUrl: './portfolio-summary.component.html', - imports: [PageComponent], + imports: [ + CommonModule, + NotFoundComponent, + PageComponent, + MatIconModule, + MatButtonModule, + SharedImports, + MatSelectModule, + MatButtonToggleModule, + AgGridAngular, + AgGridModule, + ], }) export class PortfolioSummaryComponent implements OnInit { + private _matDialog = inject(MatDialog) + private _configService = inject(ConfigService) + private _goalService = inject(GoalService) + private _organizationService = inject(OrganizationService) + private readonly _unsubscribeAll$ = new Subject() + + currentGoal: Goal + currentCycleGoal: CycleGoal + goals: Goal[] + organization: Organization + + gridTheme$ = this._configService.gridTheme$ + defaultColDef = { suppressMovable: true } + rowData = [] + columnDefs: ColDef[] = [ + { headerName: 'Cycle', field: 'baseline_cycle_name' }, + { headerName: 'Total Area. (ft**2)', field: 'baseline_total_sqft' }, + { headerName: 'Total kBTU', field: 'baseline_total_kbtu' }, + { headerName: 'EUI (kBtu/ft**2/year)', field: 'baseline_weighted_eui' }, + { headerName: 'Cycle', field: 'current_cycle_name' }, + { headerName: 'Total Area. (ft**2)', field: 'current_total_sqft' }, + { headerName: 'Total kBTU', field: 'current_total_kbtu' }, + { headerName: 'EUI (kBtu/ft**2/year)', field: 'current_weighted_eui' }, + { headerName: 'Area % Change', field: 'sqft_change' }, + { headerName: 'EUI % Improvement', field: 'eui_change' }, + ] + ngOnInit(): void { - console.log('Portfolio Summary') + this._goalService.goals$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((goals) => { + this.goals = goals + }) + this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { + this.organization = organization + }) + } + + // Dialog openers + openConfigureGoals(): void { + this._matDialog.open(AddCycleDialogComponent, { + autoFocus: false, + disableClose: true, + data: {} satisfies AddCycleData, + }) + } + + openAddCycle(): void { + this._matDialog.open(ConfigureGoalsDialogComponent, { + autoFocus: false, + disableClose: true, + data: {} satisfies ConfigureGoalsData, + }) + } + + // Selectors + selectGoal(event: MatSelectChange) { + const goalId: number = event.value as number + this.currentGoal = this.goals.find((g) => g.id === goalId) + } + + selectCycleGoal(event: MatButtonToggleChange) { + const cycleGoalId: number = event.value as number + this.currentCycleGoal = this.currentGoal.cycle_goals.find((cycleGoal) => cycleGoal.id === cycleGoalId) + + this._goalService + .getPortfolioSummary(this.currentGoal.id, this.currentCycleGoal.id, this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((portfolioSummary) => { + this.rowData = [portfolioSummary] + }) } } diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts new file mode 100644 index 00000000..2e3d7f40 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts @@ -0,0 +1,3 @@ +export type ConfigureGoalsData = object + +export type AddCycleData = object From 29dd08e1fe19b9a1316f2fef8640bb4867f0b1ac Mon Sep 17 00:00:00 2001 From: heslinge Date: Tue, 3 Feb 2026 12:08:33 -0700 Subject: [PATCH 2/6] Add portifolio summary chart --- cspell.json | 2 + package.json | 2 + pnpm-lock.yaml | 52 +++++++++ src/@seed/api/goal/goal.service.ts | 28 ++--- src/@seed/api/goal/goal.types.ts | 14 +++ .../add-cycle-dialog.component.html | 1 + .../configure-goals-dialog.component.html | 1 + .../portfolio-summary.component.html | 102 +++++++++++++++-- .../portfolio-summary.component.ts | 104 ++++++++++++++++-- 9 files changed, 265 insertions(+), 41 deletions(-) diff --git a/cspell.json b/cspell.json index 900e49ab..970964fd 100644 --- a/cspell.json +++ b/cspell.json @@ -26,10 +26,12 @@ "FEMP", "falsey", "greenbutton", + "kbtu", "movened", "NMEC", "overlaycontainer", "SRID", + "sqft", "Syncr", "ubids", "unpair", diff --git a/package.json b/package.json index d2305453..b3b2345e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "@jsverse/transloco": "^7.5.1", "ag-grid-angular": "^33.1.1", "ag-grid-community": "^33.1.1", + "chart.js": "^4.5.1", + "chartjs-plugin-annotation": "^3.1.0", "crypto-es": "^2.1.0", "cspell": "^8.17.3", "file-saver": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 058627f2..c8821ce7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,12 @@ importers: ag-grid-community: specifier: ^33.1.1 version: 33.1.1 + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + chartjs-plugin-annotation: + specifier: ^3.1.0 + version: 3.1.0(chart.js@4.5.1) crypto-es: specifier: ^2.1.0 version: 2.1.0 @@ -1953,6 +1959,9 @@ packages: '@keyv/serialize@1.0.3': resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -2072,42 +2081,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.0.1': resolution: {integrity: sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.0.1': resolution: {integrity: sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.0.1': resolution: {integrity: sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.0.1': resolution: {integrity: sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.0.1': resolution: {integrity: sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.0.1': resolution: {integrity: sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/nice-win32-arm64-msvc@1.0.1': resolution: {integrity: sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==} @@ -2233,36 +2249,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -2331,56 +2353,67 @@ packages: resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.2': resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.2': resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.2': resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} @@ -3054,6 +3087,15 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chartjs-plugin-annotation@3.1.0: + resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==} + peerDependencies: + chart.js: '>=4.0.0' + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -8686,6 +8728,8 @@ snapshots: dependencies: buffer: 6.0.3 + '@kurkle/color@0.3.4': {} + '@leichtgewicht/ip-codec@2.0.5': {} '@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@7.5.1(@types/node@22.13.9))': @@ -9815,6 +9859,14 @@ snapshots: chardet@0.7.0: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chartjs-plugin-annotation@3.1.0(chart.js@4.5.1): + dependencies: + chart.js: 4.5.1 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 diff --git a/src/@seed/api/goal/goal.service.ts b/src/@seed/api/goal/goal.service.ts index 480b1589..4798c3c2 100644 --- a/src/@seed/api/goal/goal.service.ts +++ b/src/@seed/api/goal/goal.service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, catchError, map, take, tap } from 'rxjs' import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { Goal, GoalsResponse, PortfolioSummary } from './goal.types' +import type { Goal, GoalsResponse, PortfolioSummary, weightedEUIsResponse } from './goal.types' @Injectable({ providedIn: 'root' }) export class GoalService { @@ -47,23 +47,6 @@ export class GoalService { .subscribe() } - portfolioSummary(goalId: number, cycleGoalId: number, orgId: number) { - const url = `/api/v3/goals/${goalId}/cycles/${cycleGoalId}/?organization_id=${orgId}` - this._httpClient - .get(url) - .pipe( - take(1), - map((portfolioSummary) => portfolioSummary), - tap((portfolioSummary) => { - this._portfolioSummary.next(portfolioSummary) - }), - catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, 'Error fetching goals') - }), - ) - .subscribe() - } - getPortfolioSummary(goalId: number, cycleGoalId: number, orgId: number): Observable { const url = `/api/v3/goals/${goalId}/cycles/${cycleGoalId}/portfolio_summary?organization_id=${orgId}` return this._httpClient.get(url).pipe( @@ -72,4 +55,13 @@ export class GoalService { }), ) } + + getWeightedEUIs(goalId: number, orgId: number): Observable { + const url = `/api/v3/goals/${goalId}/get_weighted_euis/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } } diff --git a/src/@seed/api/goal/goal.types.ts b/src/@seed/api/goal/goal.types.ts index 9049b60d..7b669a6b 100644 --- a/src/@seed/api/goal/goal.types.ts +++ b/src/@seed/api/goal/goal.types.ts @@ -65,3 +65,17 @@ export type PortfolioSummary = { sqft_change: number; eui_change: number; } + +export type WeightedEUI = { + 'Cycle Name': string; + 'Baseline?': string; + EUI: string; + Goal: number; + 'Annual % Imp': number; + 'Cumulative % Imp': number; +} + +export type weightedEUIsResponse = { + status: string; + results: WeightedEUI[]; +} diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html index c7ad10c1..a6459b2b 100644 --- a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html @@ -1,4 +1,5 @@
+
Add Cycle
diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html index c7ad10c1..af70680c 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html @@ -1,4 +1,5 @@
+
Configure Goals
diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html index d8c1e492..a6ca470e 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html @@ -29,15 +29,54 @@
@if (currentGoal) { - -
-
Type: {{ currentGoal.type }}
-
Baseline Cycle: {{ currentGoal.baseline_cycle_name }}
-
{{ currentGoal.level_name }}: {{ currentGoal.access_level_instance_name }}
-
Portfolio Target: {{ currentGoal.target_percentage }}
-
Commitment Sq. Ft: {{ currentGoal.commitment_sqft || 'n/a' }}
-
Area Column: {{ currentGoal.area_column_name }}
-
Primary EUI: {{ currentGoal.eui_column1_name }}
+
+ +
+
Type: {{ currentGoal.type }}
+
Baseline Cycle: {{ currentGoal.baseline_cycle_name }}
+
{{ currentGoal.level_name }}: {{ currentGoal.access_level_instance_name }}
+
Portfolio Target: {{ currentGoal.target_percentage }}
+
Commitment Sq. Ft: {{ currentGoal.commitment_sqft || 'n/a' }}
+
Area Column: {{ currentGoal.area_column_name }}
+
Primary EUI: {{ currentGoal.eui_column1_name }}
+
+ + @if (currentGoal && portfolioSummary) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Commitment (Sq. Ft){{ this.currentGoal.commitment_sqft }}
Shared (Sq. Ft){{ this.portfolioSummary.shared_sqft }}
Passing Checks (Sq. Ft){{ this.portfolioSummary.current_total_sqft }}
Passing Checks (% of committed){{ this.portfolioSummary.passing_committed }}
Passing Checks (% of shared){{ this.portfolioSummary.passing_shared }}
Total Passing Checks{{ this.portfolioSummary.total_passing }}
Total New or Acquired{{ this.portfolioSummary.total_new_or_acquired }}
+ }
@@ -52,16 +91,55 @@ @if (currentCycleGoal) { + + + + Portfolio Summary + + Unexpected Portfolio Summary Calculations? + + + {{ + t( + 'Portfolio Summary calculations only include properties that have \"Passed Checks\" and are not "New Build or Acquired" (see far right columns below).' + ) + }} + {{ t('Run Data Quality Check, available in the Actions dropdown, to auto-populate the "Passed Checks" column.') }} +
+ {{ t('Data Quality Checks can be configured in ') }} + {{ + t('Data Quality Settings.') + }} +
+
+
+ -
+
} +
+ {{ chart }} +
+ +
+ +
} diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts index 43467550..66c1cf91 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts @@ -1,17 +1,25 @@ import { CommonModule } from '@angular/common' -import type { OnInit } from '@angular/core' -import { Component, inject } from '@angular/core' +import type { ElementRef, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import type { FormControl, FormGroup } from '@angular/forms' +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import type { MatButtonToggleChange } from '@angular/material/button-toggle' import { MatButtonToggleModule } from '@angular/material/button-toggle' import { MatDialog } from '@angular/material/dialog' +import { MatExpansionModule } from '@angular/material/expansion' +import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' import type { MatSelectChange } from '@angular/material/select' import { MatSelectModule } from '@angular/material/select' +import { RouterLink } from '@angular/router' import { AgGridAngular, AgGridModule } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' +import { Chart } from 'chart.js/auto' +import annotationPlugin from 'chartjs-plugin-annotation' import { Subject, takeUntil } from 'rxjs' -import type { CycleGoal, Goal } from '@seed/api/goal' +import type { CycleGoal, Goal, PortfolioSummary, WeightedEUI } from '@seed/api/goal' import { GoalService } from '@seed/api/goal' import type { Organization } from '@seed/api/organization' import { OrganizationService } from '@seed/api/organization' @@ -22,6 +30,7 @@ import { AddCycleDialogComponent } from './add-cycle-dialog' import { ConfigureGoalsDialogComponent } from './configure-goals-dialog' import type { AddCycleData, ConfigureGoalsData } from './portfolio-summary.types' +Chart.register(annotationPlugin) @Component({ selector: 'seed-portfolio-summary', templateUrl: './portfolio-summary.component.html', @@ -36,6 +45,12 @@ import type { AddCycleData, ConfigureGoalsData } from './portfolio-summary.types MatButtonToggleModule, AgGridAngular, AgGridModule, + MatExpansionModule, + RouterLink, + MatFormFieldModule, + FormsModule, + ReactiveFormsModule, + MatInputModule, ], }) export class PortfolioSummaryComponent implements OnInit { @@ -44,16 +59,20 @@ export class PortfolioSummaryComponent implements OnInit { private _goalService = inject(GoalService) private _organizationService = inject(OrganizationService) private readonly _unsubscribeAll$ = new Subject() + @ViewChild('canvas') canvas!: ElementRef + private _formBuilder = inject(FormBuilder) + goals: Goal[] currentGoal: Goal currentCycleGoal: CycleGoal - goals: Goal[] + portfolioSummary: PortfolioSummary organization: Organization + chart: Chart<'bar', string[], string> gridTheme$ = this._configService.gridTheme$ defaultColDef = { suppressMovable: true } - rowData = [] - columnDefs: ColDef[] = [ + cycleGoalSummaryData: PortfolioSummary[] = [] + cycleGoalSummaryColumnDefs: ColDef[] = [ { headerName: 'Cycle', field: 'baseline_cycle_name' }, { headerName: 'Total Area. (ft**2)', field: 'baseline_total_sqft' }, { headerName: 'Total kBTU', field: 'baseline_total_kbtu' }, @@ -65,6 +84,15 @@ export class PortfolioSummaryComponent implements OnInit { { headerName: 'Area % Change', field: 'sqft_change' }, { headerName: 'EUI % Improvement', field: 'eui_change' }, ] + goalSummaryData: WeightedEUI[] = [] + goalSummaryColumnDefs: ColDef[] = [ + { field: 'Cycle Name' }, + { field: 'Baseline?' }, + { field: 'EUI' }, + { field: 'Goal' }, + { field: 'Annual % Imp' }, + { field: 'Cumulative % Imp' }, + ] ngOnInit(): void { this._goalService.goals$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((goals) => { @@ -76,26 +104,38 @@ export class PortfolioSummaryComponent implements OnInit { } // Dialog openers - openConfigureGoals(): void { + openAddCycle(): void { this._matDialog.open(AddCycleDialogComponent, { autoFocus: false, disableClose: true, - data: {} satisfies AddCycleData, + data: {} satisfies ConfigureGoalsData, }) } - openAddCycle(): void { + openConfigureGoals(): void { this._matDialog.open(ConfigureGoalsDialogComponent, { autoFocus: false, disableClose: true, - data: {} satisfies ConfigureGoalsData, + data: {} satisfies AddCycleData, }) } + runDataQualityChecks(): void { + console.log('runDataQualityChecks') + } + // Selectors selectGoal(event: MatSelectChange) { const goalId: number = event.value as number this.currentGoal = this.goals.find((g) => g.id === goalId) + + this._goalService + .getWeightedEUIs(this.currentGoal.id, this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe(({ results }) => { + this.createChart(results) + this.goalSummaryData = results + }) } selectCycleGoal(event: MatButtonToggleChange) { @@ -106,7 +146,49 @@ export class PortfolioSummaryComponent implements OnInit { .getPortfolioSummary(this.currentGoal.id, this.currentCycleGoal.id, this.organization.id) .pipe(takeUntil(this._unsubscribeAll$)) .subscribe((portfolioSummary) => { - this.rowData = [portfolioSummary] + this.cycleGoalSummaryData = [portfolioSummary] + this.portfolioSummary = portfolioSummary }) } + + createChart(weightedEUIs: WeightedEUI[]) { + // chart + this.chart?.destroy() + const ctx = this.canvas.nativeElement.getContext('2d') + this.chart = new Chart(ctx, { + type: 'bar', + data: { + datasets: [ + { + data: weightedEUIs.map((we) => we.EUI), + backgroundColor: ['#1E428A', ...new Array(weightedEUIs.length).fill('#06732cff')], + }, + ], + labels: weightedEUIs.map((we) => we['Cycle Name']), + }, + options: { + responsive: true, + plugins: { + legend: { + display: false, + }, + title: { + display: true, + text: 'Energy Use Intensity by Reporting Period', + }, + annotation: { + annotations: { + line1: { + type: 'line', + yMin: weightedEUIs[0].Goal, + yMax: weightedEUIs[0].Goal, + borderWidth: 2, + borderDash: [4], + }, + }, + }, + }, + }, + }) + } } From c09c0072684abf1c32d82dadc37c562a441fada2 Mon Sep 17 00:00:00 2001 From: heslinge Date: Wed, 4 Feb 2026 11:46:42 -0700 Subject: [PATCH 3/6] Add Partner goals --- cspell.json | 1 + src/@seed/api/goal/goal.service.ts | 9 +++ src/@seed/api/goal/goal.types.ts | 2 +- .../portfolio-summary.component.html | 50 ++++++++++++--- .../portfolio-summary.component.ts | 64 +++++++++++++++++-- 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/cspell.json b/cspell.json index 970964fd..34e33edb 100644 --- a/cspell.json +++ b/cspell.json @@ -23,6 +23,7 @@ "CEJST", "eeej", "EPSG", + "euis", "FEMP", "falsey", "greenbutton", diff --git a/src/@seed/api/goal/goal.service.ts b/src/@seed/api/goal/goal.service.ts index 4798c3c2..d6d88eb1 100644 --- a/src/@seed/api/goal/goal.service.ts +++ b/src/@seed/api/goal/goal.service.ts @@ -64,4 +64,13 @@ export class GoalService { }), ) } + + editGoal(goalId: number, editedGoal, orgId: number): Observable { + const url = `/api/v3/goals/${goalId}/?organization_id=${orgId}` + return this._httpClient.put(url, editedGoal).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } } diff --git a/src/@seed/api/goal/goal.types.ts b/src/@seed/api/goal/goal.types.ts index 7b669a6b..ea654fb5 100644 --- a/src/@seed/api/goal/goal.types.ts +++ b/src/@seed/api/goal/goal.types.ts @@ -30,7 +30,7 @@ export type Goal = { partner_note: string; partner_note_approval: boolean; partner_note_approval_time?: string; - partner_note_approval_user?: string; + partner_note_approval_user?: number; salesforce_goal_id?: string; salesforce_goal_name?: string; salesforce_partner_id?: string; diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html index a6ca470e..a97477d5 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html @@ -30,7 +30,7 @@ @if (currentGoal) {
- +
Type: {{ currentGoal.type }}
Baseline Cycle: {{ currentGoal.baseline_cycle_name }}
@@ -41,6 +41,7 @@
Primary EUI: {{ currentGoal.eui_column1_name }}
+ @if (currentGoal && portfolioSummary) { @@ -49,31 +50,31 @@ - + - + - + - + - + - + - +
Commitment (Sq. Ft){{ this.currentGoal.commitment_sqft }}{{ currentGoal.commitment_sqft }}
Shared (Sq. Ft){{ this.portfolioSummary.shared_sqft }}{{ portfolioSummary.shared_sqft }}
Passing Checks (Sq. Ft){{ this.portfolioSummary.current_total_sqft }}{{ portfolioSummary.current_total_sqft }}
Passing Checks (% of committed){{ this.portfolioSummary.passing_committed }}{{ portfolioSummary.passing_committed }}
Passing Checks (% of shared){{ this.portfolioSummary.passing_shared }}{{ portfolioSummary.passing_shared }}
Total Passing Checks{{ this.portfolioSummary.total_passing }}{{ portfolioSummary.total_passing }}
Total New or Acquired{{ this.portfolioSummary.total_new_or_acquired }}{{ portfolioSummary.total_new_or_acquired }}
} @@ -90,6 +91,7 @@ {{ t('Add Cycle') }} + @if (currentCycleGoal) { @@ -128,10 +130,40 @@ />
} + + +
+
+ + Note + + +
+ @if (partnerNoteForm.disabled) { + + } @else { + + } +
+
+ + Partner Approval + + @if (currentGoal.partner_note_approval) { +
+ Approved at {{ currentGoal.partner_note_approval_time }} by {{ currentGoal.partner_note_approval_user }} +
+ } +
+ +
{{ chart }}
-
() @ViewChild('canvas') canvas!: ElementRef - private _formBuilder = inject(FormBuilder) + private _userService = inject(UserService) goals: Goal[] currentGoal: Goal @@ -68,6 +71,10 @@ export class PortfolioSummaryComponent implements OnInit { portfolioSummary: PortfolioSummary organization: Organization chart: Chart<'bar', string[], string> + currentUser: CurrentUser + partnerNoteForm = new FormGroup({ + text: new FormControl({ value: '', disabled: true }), + }) gridTheme$ = this._configService.gridTheme$ defaultColDef = { suppressMovable: true } @@ -101,6 +108,9 @@ export class PortfolioSummaryComponent implements OnInit { this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { this.organization = organization }) + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) } // Dialog openers @@ -120,14 +130,60 @@ export class PortfolioSummaryComponent implements OnInit { }) } + // button pushes runDataQualityChecks(): void { console.log('runDataQualityChecks') } + setEditingPartnerNote(isEditing: boolean): void { + if (isEditing) { + this.partnerNoteForm.enable() + } else { + this.partnerNoteForm.disable() + } + } + + savePartnerNote(): void { + this.currentGoal.partner_note = this.partnerNoteForm.value.text + this._goalService + .editGoal( + this.currentGoal.id, + { + partner_note: this.currentGoal.partner_note, + }, + this.organization.id, + ) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe(() => { + this.setEditingPartnerNote(false) + }) + } + + changePartnerNoteApproval(isApproved: boolean): void { + this.currentGoal.partner_note_approval = isApproved + this.currentGoal.partner_note_approval_time = isApproved ? new Date().toJSON() : null + this.currentGoal.partner_note_approval_user = isApproved ? this.currentUser.id : null + this._goalService + .editGoal( + this.currentGoal.id, + { + partner_note_approval: this.currentGoal.partner_note_approval, + partner_note_approval_time: this.currentGoal.partner_note_approval_time, + partner_note_approval_user: this.currentGoal.partner_note_approval_user, + }, + this.organization.id, + ) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((editedGoal) => { + this.currentGoal = editedGoal + }) + } + // Selectors selectGoal(event: MatSelectChange) { const goalId: number = event.value as number this.currentGoal = this.goals.find((g) => g.id === goalId) + this.partnerNoteForm.setValue({ text: this.currentGoal.partner_note }) this._goalService .getWeightedEUIs(this.currentGoal.id, this.organization.id) @@ -151,8 +207,8 @@ export class PortfolioSummaryComponent implements OnInit { }) } + // chart createChart(weightedEUIs: WeightedEUI[]) { - // chart this.chart?.destroy() const ctx = this.canvas.nativeElement.getContext('2d') this.chart = new Chart(ctx, { From 54f27ee3492c34f8659292f7be28eb5a3110ec8d Mon Sep 17 00:00:00 2001 From: heslinge Date: Tue, 10 Feb 2026 11:10:04 -0700 Subject: [PATCH 4/6] Make Configure goals dialog --- src/@seed/api/goal/goal.service.ts | 9 ++ src/@seed/api/goal/goal.types.ts | 6 +- .../configure-goals-dialog.component.html | 147 +++++++++++++++++- .../configure-goals-dialog.component.ts | 139 ++++++++++++++++- .../portfolio-summary.component.ts | 6 +- .../portfolio-summary.types.ts | 6 +- 6 files changed, 299 insertions(+), 14 deletions(-) diff --git a/src/@seed/api/goal/goal.service.ts b/src/@seed/api/goal/goal.service.ts index d6d88eb1..17e2abca 100644 --- a/src/@seed/api/goal/goal.service.ts +++ b/src/@seed/api/goal/goal.service.ts @@ -73,4 +73,13 @@ export class GoalService { }), ) } + + createGoal(newGoal, orgId: number): Observable { + const url = `/api/v3/goals/?organization_id=${orgId}` + return this._httpClient.post(url, { ...newGoal, organization: orgId }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } } diff --git a/src/@seed/api/goal/goal.types.ts b/src/@seed/api/goal/goal.types.ts index ea654fb5..be21187d 100644 --- a/src/@seed/api/goal/goal.types.ts +++ b/src/@seed/api/goal/goal.types.ts @@ -18,9 +18,9 @@ export type Goal = { commitment_sqft: number; eui_column1: number; eui_column1_name: string; - eui_column2?: string; + eui_column2?: number; eui_column2_name?: string; - eui_column3?: string; + eui_column3?: number; eui_column3_name?: string; id: number; level_name: string; @@ -37,7 +37,7 @@ export type Goal = { salesforce_partner_name?: string; target_percentage: number; transactions_column?: string; - type: string; + type: 'standard' | 'transaction'; access_level_instance_name: string; cycle_goals: CycleGoal[]; } diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html index af70680c..33934009 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html @@ -1,6 +1,147 @@ -
-
Configure Goals
-
+
+ +
+
Configure Goals
+
+ +
+ +
+
{{ t('GOALS') }}
+ + @for (goal of goals; track goal.id) { + {{ goal.name }} + } + {{ t('+ New Goal') }} + +
+ +
+ +
{{ t('GOAL') }}
+ + {{ t('Name') }} + + + + Connection + + {{ t('standard') }} + {{ t('transaction') }} + + {{ + t( + 'Select a Goal Type. A Standard goal will calculate a standard EUI per sqft. A Transaction goal will also calculate an EUI per transaction.' + ) + }} + + + +
{{ t('CYCLE SELECTION') }}
+ + Baseline Cycle + + @for (cycle of cycles; track cycle.id) { + {{ cycle.name }} + } + + {{ t('Select a cycle to use as the baseline for comparison.') }} + + + +
{{ t('ACCESS LEVEL INSTANCE') }}
+ + Access Level + + @for (level of accessLevelNames; track level) { + {{ level }} + } + + + + + Access Level Instance + + @for (ali of accessLevelInstances; track ali.id) { + {{ ali.name }} + } + + + + +
{{ t('AREA TARGET COLUMN') }}
+ + Area Column + + @for (column of areaColumns; track column.id) { + {{ column.display_name }} + } + + {{ t('Not seeing your column? Update the column\'s data type to "Area" in Column Settings') }} + + + +
{{ t('EUI TARGET COLUMNS') }}
+ + Primary Column + + @for (column of euiColumns; track column.id) { + {{ column.display_name }} + } + + {{ t('Not seeing your column? Update the column\'s data type to "Area" in Column Settings') }} + + @if (goalForm.value.euiColumn1) { + + Secondary Column + + -- + @for (column of euiColumns; track column.id) { + {{ column.display_name }} + } + + + } + @if (goalForm.value.euiColumn2) { + + Secondary Column + + -- + @for (column of euiColumns; track column.id) { + {{ column.display_name }} + } + + + } + + +
{{ t('PORTFOLIO TARGET') }}
+ + {{ t('Percentage (%)') }} + + {{ t('Target to quantify Portfolio EUI improvement. Must be between 0 and 100.') }} + + + +
{{ t('COMMITMENT') }}
+ + {{ t('Commitment (Sq Ft)') }} + + {{ t('Committed Area') }} + +
+
+ + +
+
diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts index bc57ba30..627e3c3e 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts @@ -1,15 +1,26 @@ +import { CdkScrollable } from '@angular/cdk/scrolling' import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' -import { Component, ViewEncapsulation } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { Component, inject, ViewEncapsulation } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' -import { MatDialogModule } from '@angular/material/dialog' +import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog' import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' -import { Subject } from 'rxjs' +import { MatSelectModule } from '@angular/material/select' +import { Subject, takeUntil } from 'rxjs' +import type { Column } from '@seed/api/column' +import { ColumnService } from '@seed/api/column' +import { type Cycle, CycleService } from '@seed/api/cycle' +import type { Goal } from '@seed/api/goal' +import { GoalService } from '@seed/api/goal' +import type { AccessLevelInstancesByDepth, AccessLevelsByDepth, Organization } from '@seed/api/organization' +import { OrganizationService } from '@seed/api/organization' import { SharedImports } from '@seed/directives' +import type { ConfigureGoalsData } from '../portfolio-summary.types' @Component({ selector: 'seed-configure-goals-dialog', @@ -26,13 +37,131 @@ import { SharedImports } from '@seed/directives' MatProgressSpinnerModule, ReactiveFormsModule, SharedImports, + MatSelectModule, + CdkScrollable, + MatButtonToggleModule, ], }) export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { private readonly _unsubscribeAll$ = new Subject() + data = inject(MAT_DIALOG_DATA) as ConfigureGoalsData + goalForm = new FormGroup({ + name: new FormControl('', Validators.required), + type: new FormControl<'standard' | 'transaction' | null>(null, Validators.required), + baselineCycle: new FormControl(null, Validators.required), + accessLevel: new FormControl(null, Validators.required), + accessLevelInstanceId: new FormControl(null, Validators.required), + areaColumn: new FormControl(null, Validators.required), + euiColumn1: new FormControl(null, Validators.required), + euiColumn2: new FormControl(null), + euiColumn3: new FormControl(null), + targetPercentage: new FormControl(null, Validators.required), + commitmentSqft: new FormControl(null, Validators.required), + }) + private _cycleService = inject(CycleService) + cycles: Cycle[] + accessLevelNames: AccessLevelInstancesByDepth['accessLevelNames'] + accessLevelInstancesByDepth: AccessLevelsByDepth = {} + accessLevelInstances: AccessLevelsByDepth[keyof AccessLevelsByDepth] = [] + private _organizationService = inject(OrganizationService) + private _columnService = inject(ColumnService) + areaColumns: Column[] = [] + euiColumns: Column[] = [] + goals: Goal[] + currentGoal?: Goal + private _goalService = inject(GoalService) + organization: Organization ngOnInit(): void { - console.log('ConfigureGoalsDialogComponent') + this.goals = this.data.goals + this._cycleService.cycles$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((cycles) => { + this.cycles = cycles + console.log(this.cycles) + }) + this._organizationService.accessLevelTree$.pipe(takeUntil(this._unsubscribeAll$)).subscribe(({ accessLevelNames }) => { + this.accessLevelNames = accessLevelNames + }) + this._organizationService.accessLevelInstancesByDepth$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((accessLevelsByDepth) => { + this.accessLevelInstancesByDepth = accessLevelsByDepth + }) + this._columnService.propertyColumns$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((propertyColumns) => { + this.areaColumns = propertyColumns.filter((c) => c.data_type == 'area') + this.euiColumns = propertyColumns.filter((c) => c.data_type == 'eui') + }) + this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { + this.organization = organization + }) + if (this.goals.length > 0) { + this.currentGoal = this.goals[0] + this.selectGoal(this.goals[0].id) + } + } + + selectGoal(goalId?: number) { + if (goalId == null) { + // new goal + this.goalForm.reset() + this.currentGoal = null + } else { + // old goal + this.currentGoal = this.goals.find((g) => g.id === goalId) + this.onAccessLevelChange(this.currentGoal.level_name) + this.goalForm.setValue({ + name: this.currentGoal.name, + type: this.currentGoal.type, + baselineCycle: this.currentGoal.baseline_cycle, + accessLevel: this.currentGoal.level_name, + accessLevelInstanceId: this.currentGoal.access_level_instance, + areaColumn: this.currentGoal.area_column, + euiColumn1: this.currentGoal.eui_column1, + euiColumn2: this.currentGoal.eui_column2, + euiColumn3: this.currentGoal.eui_column3, + targetPercentage: this.currentGoal.target_percentage, + commitmentSqft: this.currentGoal.commitment_sqft, + }) + } + } + + onAccessLevelChange(accessLevelName: string) { + const depth = this.accessLevelNames.findIndex((name) => name === accessLevelName) + this.accessLevelInstances = this.accessLevelInstancesByDepth[depth] + } + + save(): void { + const formValues = this.goalForm.value + const request_data = { + name: formValues.name, + type: formValues.type, + baseline_cycle: formValues.baselineCycle, + access_level_instance: formValues.accessLevelInstanceId, + area_column: formValues.areaColumn, + eui_column1: formValues.euiColumn1, + eui_column2: formValues.euiColumn2, + eui_column3: formValues.euiColumn3, + target_percentage: formValues.targetPercentage, + commitment_sqft: formValues.commitmentSqft, + } + + if (this.currentGoal == null) { + // create new goal + this._goalService + .createGoal(request_data, this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((goal) => { + this.currentGoal = goal + this.goals.push(goal) + }) + } else { + // edit old goal + this._goalService + .editGoal(this.currentGoal.id, request_data, this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((goal) => { + this.currentGoal = goal + const currentGoalIndex = this.goals.findIndex((g) => g.id === this.currentGoal.id) + this.goals[currentGoalIndex] = goal + }) + } } ngOnDestroy(): void { diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts index 7794f576..ccf4f6bc 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts @@ -118,7 +118,7 @@ export class PortfolioSummaryComponent implements OnInit { this._matDialog.open(AddCycleDialogComponent, { autoFocus: false, disableClose: true, - data: {} satisfies ConfigureGoalsData, + data: {} satisfies AddCycleData, }) } @@ -126,7 +126,9 @@ export class PortfolioSummaryComponent implements OnInit { this._matDialog.open(ConfigureGoalsDialogComponent, { autoFocus: false, disableClose: true, - data: {} satisfies AddCycleData, + width: '50rem', + height: '50rem', + data: { goals: this.goals } satisfies ConfigureGoalsData, }) } diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts index 2e3d7f40..5062ad05 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts @@ -1,3 +1,7 @@ -export type ConfigureGoalsData = object +import type { Goal } from '@seed/api/goal' + +export type ConfigureGoalsData = { + goals: Goal[]; +} export type AddCycleData = object From e06e64942d5188a550cc042d8452d4f2e05f0fbd Mon Sep 17 00:00:00 2001 From: heslinge Date: Fri, 6 Mar 2026 16:01:22 -0700 Subject: [PATCH 5/6] Add salesforce login and add cycle goal dialog --- src/@seed/api/goal/goal.service.ts | 16 ++- .../api/organization/organization.types.ts | 1 + src/@seed/api/salesforce-portfolio/index.ts | 2 + .../salesforce-portfolio.service.ts | 100 +++++++++++++++ .../salesforce-portfolio.types.ts | 55 ++++++++ src/app/app.routes.ts | 2 + .../add-cycle-dialog.component.html | 44 ++++++- .../add-cycle-dialog.component.ts | 63 +++++++++- .../configure-goals-dialog.component.html | 32 +++++ .../configure-goals-dialog.component.ts | 63 +++++++--- .../portfolio-summary.component.html | 16 +++ .../portfolio-summary.component.ts | 44 +++++-- .../portfolio-summary.types.ts | 8 +- .../modal/delete-modal.component.html | 0 .../modal/delete-modal.component.ts | 0 .../modal/form-modal.component.html | 0 .../modal/form-modal.component.ts | 0 .../modal/index.ts | 0 ...force-building-integration.component.html} | 7 +- ...esforce-building-integration.component.ts} | 6 +- ...force-portfolio-integration.component.html | 72 +++++++++++ ...force-portfolio-integration.component.scss | 0 ...ce-portfolio-integration.component.spec.ts | 22 ++++ ...esforce-portfolio-integration.component.ts | 117 ++++++++++++++++++ .../settings/settings.component.ts | 12 +- .../organizations/settings/settings.routes.ts | 14 ++- .../salesforce-login.component.html | 1 + .../salesforce-login.component.scss | 0 .../salesforce-login.component.spec.ts | 23 ++++ .../salesforce-login.component.ts | 34 +++++ 30 files changed, 716 insertions(+), 38 deletions(-) create mode 100644 src/@seed/api/salesforce-portfolio/index.ts create mode 100644 src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts create mode 100644 src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts rename src/app/modules/organizations/settings/{salesforce => salesforce-building-integration}/modal/delete-modal.component.html (100%) rename src/app/modules/organizations/settings/{salesforce => salesforce-building-integration}/modal/delete-modal.component.ts (100%) rename src/app/modules/organizations/settings/{salesforce => salesforce-building-integration}/modal/form-modal.component.html (100%) rename src/app/modules/organizations/settings/{salesforce => salesforce-building-integration}/modal/form-modal.component.ts (100%) rename src/app/modules/organizations/settings/{salesforce => salesforce-building-integration}/modal/index.ts (100%) rename src/app/modules/organizations/settings/{salesforce/salesforce.component.html => salesforce-building-integration/salesforce-building-integration.component.html} (99%) rename src/app/modules/organizations/settings/{salesforce/salesforce.component.ts => salesforce-building-integration/salesforce-building-integration.component.ts} (97%) create mode 100644 src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html create mode 100644 src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.scss create mode 100644 src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.spec.ts create mode 100644 src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts create mode 100644 src/app/modules/salesforce-login/salesforce-login.component.html create mode 100644 src/app/modules/salesforce-login/salesforce-login.component.scss create mode 100644 src/app/modules/salesforce-login/salesforce-login.component.spec.ts create mode 100644 src/app/modules/salesforce-login/salesforce-login.component.ts diff --git a/src/@seed/api/goal/goal.service.ts b/src/@seed/api/goal/goal.service.ts index 17e2abca..0df43ee3 100644 --- a/src/@seed/api/goal/goal.service.ts +++ b/src/@seed/api/goal/goal.service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, catchError, map, take, tap } from 'rxjs' import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { Goal, GoalsResponse, PortfolioSummary, weightedEUIsResponse } from './goal.types' +import type { CycleGoal, Goal, GoalsResponse, PortfolioSummary, weightedEUIsResponse } from './goal.types' @Injectable({ providedIn: 'root' }) export class GoalService { @@ -25,6 +25,7 @@ export class GoalService { .pipe( tap(({ org_id }) => { this.get(org_id) + this.orgId = org_id }), ) .subscribe() @@ -82,4 +83,17 @@ export class GoalService { }), ) } + + createCycleGoal(goalId: number, cycleId: number, annual_report_id: string, annual_report_name: string): Observable { + const url = `/api/v3/goals/${goalId}/cycles/?organization_id=${this.orgId}` + return this._httpClient.post(url, { + current_cycle: cycleId, + salesforce_annual_report_id: annual_report_id, + salesforce_annual_report_name: annual_report_name + }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } } diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index 160f792d..c583a179 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -25,6 +25,7 @@ export type BriefOrganization = { user_role: UserRole; display_decimal_places: number; salesforce_enabled: boolean; + bb_salesforce_enabled: boolean; access_level_names: string[]; audit_template_conditional_import: boolean; property_display_field: string; diff --git a/src/@seed/api/salesforce-portfolio/index.ts b/src/@seed/api/salesforce-portfolio/index.ts new file mode 100644 index 00000000..93dd160c --- /dev/null +++ b/src/@seed/api/salesforce-portfolio/index.ts @@ -0,0 +1,2 @@ +export * from './salesforce-portfolio.service' +export * from './salesforce-portfolio.types' diff --git a/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts new file mode 100644 index 00000000..f8e3e0e7 --- /dev/null +++ b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts @@ -0,0 +1,100 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { catchError, map, takeUntil, tap, Subject } from 'rxjs' +import { ErrorService } from '@seed/services' +import type { SalesforcePortfolioConfig, SalesforcePortfolioConfigResponse, verifyTokenResponse, loginUrlResponse, getTokenResponse, getPartnersResponse } from './salesforce-portfolio.types' +import { UserService } from '../user' + +@Injectable({ + providedIn: 'root', +}) +export class SalesforcePortfolioService { + private _httpClient = inject(HttpClient) + private _errorService = inject(ErrorService) + private _userService = inject(UserService) + private readonly _unsubscribeAll$ = new Subject() + orgId: number + + constructor() { + this._userService.currentOrganizationId$ + .pipe( + takeUntil(this._unsubscribeAll$), + tap((orgId) => { + this.orgId = orgId + }), + ) + .subscribe() + } + + getConfig(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/configs/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + map((response) => response.bb_salesforce_configs), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } + + getToken(code: string, organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/get_token/?organization_id=${organizationId}&code=${code}` + return this._httpClient.get(url).pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } + + getPartners(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/partners/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } + + getAnnualReports(goalId: number): Observable { + const url = `/api/v3/bb_salesforce/annual_report/?organization_id=${this.orgId}&goal_id=${goalId}` + return this._httpClient.get(url).pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } + + updateConfig(updatedSalesforcePortfolioConfig: SalesforcePortfolioConfig, organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/configs/update_config/?organization_id=${organizationId}` + return this._httpClient.put(url, { ...updatedSalesforcePortfolioConfig }).pipe( + map((response) => response.bb_salesforce_configs), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } + + verifyToken(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/verify_token/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } + + getLoginUrl(organizationId: number): Observable { + const url = `/api/v3/bb_salesforce/login_url/?organization_id=${organizationId}` + return this._httpClient.get(url).pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) + } +} diff --git a/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts new file mode 100644 index 00000000..f0ad45cf --- /dev/null +++ b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts @@ -0,0 +1,55 @@ +export type SalesforcePortfolioConfig = { + organization?: number; + salesforce_url?: string; + client_id?: string; + client_secret?: string; +} + +export type SalesforcePortfolioConfigResponse = { + status: string; + bb_salesforce_configs: SalesforcePortfolioConfig; +} + +export type verifyTokenResponse = { + status: string; + valid: boolean; + message: string; +} + +export type getTokenResponse = { + status: string; + response: string; +} + +export type SalesforceGoal = { + id: string; + name: string; +} + +export type SalesforcePartner = { + id: string; + name: string; + goals: SalesforceGoal[]; +} + +export type getPartnersResponse = { + status: string; + results: SalesforcePartner[]; +} + + +export type AnnualReport = { + id: string; + name: string; +} + +export type getAnnualReportsResponse = { + status: string; + results: AnnualReport[]; +} + +export type loginUrlResponse = { + status: string; + message?: string; + url?: string; +} \ No newline at end of file diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 52bfbd88..5d074a21 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { ContactComponent } from './modules/main/contact/contact.component' import { DocumentationComponent } from './modules/main/documentation/documentation.component' import { HomeComponent } from './modules/main/home/home.component' import { ProfileComponent } from './modules/profile/profile.component' +import { SalesforceLoginComponent } from './modules/salesforce-login/salesforce-login.component' const inventoryTypeMatcher = (segments: UrlSegment[]) => { const [type, ..._] = segments @@ -84,6 +85,7 @@ export const appRoutes: Route[] = [ { path: 'about', title: 'About', component: AboutComponent }, { path: 'contact', title: 'Contact', component: ContactComponent }, { path: 'documentation', title: 'Documentation', component: DocumentationComponent }, + { path: 'salesforce-login', title: 'Salesforce Login', component: SalesforceLoginComponent }, { path: 'organizations', loadChildren: () => import('app/modules/organizations/organizations.routes'), diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html index a6459b2b..3884ce2b 100644 --- a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html @@ -1,6 +1,48 @@
-
Add Cycle
+ +
+
{{ t('Add Cycle') }}
+
+
{{ t('Add a new cycle to the selected goal to compare against the baseline. The latest cycle added will be considered the "current" cycle for comparison purposes. Older cycles added will remain available for historical reference.') }}
+ + +
+ Current Cycle: + + @for (cycle of cycles; track cycle.id) { + {{ cycle.name }} + } + +
+ + + +
+ Annual Report + + @for (annualReport of annualReports; track annualReport.id) { + {{ annualReport.name }} + } + +
+ + @if (!isLoggedIntoBbSalesforce){ +
{{ t('You must be logged in to salesforce to select a Annual Report') }}
+ } +
+
diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts index e365f600..ac9dcd89 100644 --- a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts @@ -1,15 +1,24 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' +import { inject } from '@angular/core' import { Component, ViewEncapsulation } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' -import { MatDialogModule } from '@angular/material/dialog' +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog' import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' import { Subject } from 'rxjs' import { SharedImports } from '@seed/directives' +import { type Cycle, CycleService } from '@seed/api/cycle' +import { type Goal, GoalService } from '@seed/api/goal' +import { catchError, combineLatest, map, of, ReplaySubject, switchMap, tap, takeUntil } from 'rxjs' +import type { MatSelectChange } from '@angular/material/select' +import { MatSelectModule } from '@angular/material/select' +import { AnnualReport, SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' +import { MAT_DIALOG_DATA } from '@angular/material/dialog' +import type { AddCycleData, ConfigureGoalsData } from '../portfolio-summary.types' @Component({ selector: 'seed-add-cycle-dialog', @@ -26,17 +35,67 @@ import { SharedImports } from '@seed/directives' MatProgressSpinnerModule, ReactiveFormsModule, SharedImports, + MatSelectModule, ], }) export class AddCycleDialogComponent implements OnInit, OnDestroy { private readonly _unsubscribeAll$ = new Subject() + private _cycleService = inject(CycleService) + private _goalService = inject(GoalService) + private _salesforcePortfolioService = inject(SalesforcePortfolioService) + cycles: Cycle[] = [] + selectedCycle?: Cycle = null; + selectedAnnualReport?: AnnualReport = null; + data = inject(MAT_DIALOG_DATA) as AddCycleData + isLoggedIntoBbSalesforce: boolean + annualReports: AnnualReport[] = [] + private _dialogRef = inject(MatDialogRef) ngOnInit(): void { - console.log('AddCycleDialogComponent') + this._cycleService.cycles$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((cycles) => { + this.cycles = cycles + }) + + this.isLoggedIntoBbSalesforce = this.data.isLoggedIntoBbSalesforce + if (this.isLoggedIntoBbSalesforce) { + this._salesforcePortfolioService.getAnnualReports(this.data.currentGoal.id).subscribe(annualReports => { + this.annualReports = annualReports.results + }) + } } ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() } + + selectCycle(event: MatSelectChange): void { + console.log(event) + console.log(event.value) + const selectedCycleId: number = event.value as number + this.selectedCycle = this.cycles.find((c) => c.id === selectedCycleId) + console.log(this.selectedCycle) + } + + selectAnnualReport(event: MatSelectChange): void { + console.log(event) + console.log(event.value) + const selectedAnnualReportId: string = event.value as string + this.selectedAnnualReport = this.annualReports.find((r) => r.id === selectedAnnualReportId) + console.log(this.selectedAnnualReport) + } + + submit(): void { + console.log(this.selectedAnnualReport) + console.log(this.selectedCycle) + this._goalService.createCycleGoal( + this.data.currentGoal.id, + this.selectedCycle.id, + this.selectedAnnualReport.id, + this.selectedAnnualReport.name, + ).subscribe(newCycleGoal => { + console.log(newCycleGoal) + this._dialogRef.close(newCycleGoal) + }) + } } diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html index 33934009..dc0875e4 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html @@ -136,6 +136,38 @@ {{ t('Committed Area') }} + + + @if (bb_salesforce_enabled) { +
{{ t('SALESFORCE') }}
+ @if (isLoggedIntoBbSalesforce) { + + + Salesforce Partner + + -- + @for (partner of salesforcePartners; track partner.id) { + {{ partner.name }} + } + + + + + Salesforce Goals + + @for (salesforceGoal of salesforceGoals; track salesforceGoal.id) { + {{ salesforceGoal.name }} + } + + + + } @else{ +
+ +

{{ t('Log in to Salesforce first to be able to configure Salesforce Partner and Goal associations here.')}}

+
+ } + }
diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts index 627e3c3e..506d7a3a 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts @@ -1,4 +1,3 @@ -import { CdkScrollable } from '@angular/cdk/scrolling' import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject, ViewEncapsulation } from '@angular/core' @@ -11,7 +10,7 @@ import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' import { MatSelectModule } from '@angular/material/select' -import { Subject, takeUntil } from 'rxjs' +import { Subject, takeUntil, combineLatest, switchMap } from 'rxjs' import type { Column } from '@seed/api/column' import { ColumnService } from '@seed/api/column' import { type Cycle, CycleService } from '@seed/api/cycle' @@ -21,6 +20,8 @@ import type { AccessLevelInstancesByDepth, AccessLevelsByDepth, Organization } f import { OrganizationService } from '@seed/api/organization' import { SharedImports } from '@seed/directives' import type { ConfigureGoalsData } from '../portfolio-summary.types' +import { SalesforcePartner, SalesforceGoal } from '@seed/api/salesforce-portfolio/salesforce-portfolio.types' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' @Component({ selector: 'seed-configure-goals-dialog', @@ -38,7 +39,6 @@ import type { ConfigureGoalsData } from '../portfolio-summary.types' ReactiveFormsModule, SharedImports, MatSelectModule, - CdkScrollable, MatButtonToggleModule, ], }) @@ -57,6 +57,8 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { euiColumn3: new FormControl(null), targetPercentage: new FormControl(null, Validators.required), commitmentSqft: new FormControl(null, Validators.required), + salesforcePartnerID: new FormControl(null, Validators.required), + salesforceGoalID: new FormControl(null, Validators.required), }) private _cycleService = inject(CycleService) cycles: Cycle[] @@ -71,30 +73,45 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { currentGoal?: Goal private _goalService = inject(GoalService) organization: Organization + isLoggedIntoBbSalesforce: boolean + bb_salesforce_enabled: boolean + private _salesforcePortfolioService = inject(SalesforcePortfolioService) + salesforcePartners: SalesforcePartner[]; + salesforceGoals: SalesforceGoal[]; ngOnInit(): void { + this.isLoggedIntoBbSalesforce = this.data.isLoggedIntoBbSalesforce; + this.bb_salesforce_enabled = this.data.bb_salesforce_enabled; this.goals = this.data.goals - this._cycleService.cycles$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((cycles) => { - this.cycles = cycles - console.log(this.cycles) + + this._organizationService.currentOrganization$.pipe( + takeUntil(this._unsubscribeAll$), + switchMap((organization) => { + this.organization = organization + return this._salesforcePortfolioService.getPartners(this.organization.id) }) - this._organizationService.accessLevelTree$.pipe(takeUntil(this._unsubscribeAll$)).subscribe(({ accessLevelNames }) => { - this.accessLevelNames = accessLevelNames + ).subscribe((r) => { + this.salesforcePartners = r.results; }) - this._organizationService.accessLevelInstancesByDepth$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((accessLevelsByDepth) => { + + combineLatest([ + this._cycleService.cycles$, + this._organizationService.accessLevelTree$, + this._organizationService.accessLevelInstancesByDepth$, + this._columnService.propertyColumns$, + ]).pipe(takeUntil(this._unsubscribeAll$)) + .subscribe(([cycles, {accessLevelNames}, accessLevelsByDepth, propertyColumns]) => { + this.cycles = cycles + this.accessLevelNames = accessLevelNames this.accessLevelInstancesByDepth = accessLevelsByDepth - }) - this._columnService.propertyColumns$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((propertyColumns) => { this.areaColumns = propertyColumns.filter((c) => c.data_type == 'area') this.euiColumns = propertyColumns.filter((c) => c.data_type == 'eui') }) - this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { - this.organization = organization - }) + if (this.goals.length > 0) { this.currentGoal = this.goals[0] this.selectGoal(this.goals[0].id) - } + } } selectGoal(goalId?: number) { @@ -106,7 +123,10 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { // old goal this.currentGoal = this.goals.find((g) => g.id === goalId) this.onAccessLevelChange(this.currentGoal.level_name) + this.onPartnerChange(this.currentGoal.salesforce_partner_id) this.goalForm.setValue({ + salesforcePartnerID: this.currentGoal.salesforce_partner_id, + salesforceGoalID: this.currentGoal.salesforce_goal_id, name: this.currentGoal.name, type: this.currentGoal.type, baselineCycle: this.currentGoal.baseline_cycle, @@ -127,8 +147,17 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { this.accessLevelInstances = this.accessLevelInstancesByDepth[depth] } + onPartnerChange(partnerId: string) { + const partner = this.salesforcePartners.find(p => p.id == partnerId) + this.salesforceGoals = partner.goals + } + + save(): void { const formValues = this.goalForm.value + const partner = this.salesforcePartners.find(p => p.id == formValues.salesforcePartnerID) + const goal = partner.goals.find(g => g.id == formValues.salesforceGoalID) + const request_data = { name: formValues.name, type: formValues.type, @@ -140,6 +169,10 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { eui_column3: formValues.euiColumn3, target_percentage: formValues.targetPercentage, commitment_sqft: formValues.commitmentSqft, + salesforce_partner_id: partner.id, + salesforce_partner_name: partner.name, + salesforce_goal_id: goal.id, + salesforce_goal_name: goal.name, } if (this.currentGoal == null) { diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html index a97477d5..a892785f 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html @@ -28,6 +28,22 @@
+ @if (organization && organization.bb_salesforce_enabled) { +

Salesforce portfolio functionality is enabled for this organization. Configure connection details by clicking on

+ {{ t('Salesforce settings') }} +
+ Salesforce Connection Status: + @if (isLoggedIntoBbSalesforce) { + check_circle + + } @else { + cancel + + } +
+ } + + @if (currentGoal) {
diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts index ccf4f6bc..efb6504e 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts @@ -18,7 +18,7 @@ import { AgGridAngular, AgGridModule } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' import { Chart } from 'chart.js/auto' import annotationPlugin from 'chartjs-plugin-annotation' -import { Subject, takeUntil } from 'rxjs' +import { Subject, takeUntil, switchMap } from 'rxjs' import type { CycleGoal, Goal, PortfolioSummary, WeightedEUI } from '@seed/api/goal' import { GoalService } from '@seed/api/goal' import type { Organization } from '@seed/api/organization' @@ -31,6 +31,8 @@ import { ConfigService } from '@seed/services' import { AddCycleDialogComponent } from './add-cycle-dialog' import { ConfigureGoalsDialogComponent } from './configure-goals-dialog' import type { AddCycleData, ConfigureGoalsData } from './portfolio-summary.types' +import { Router } from '@angular/router' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' Chart.register(annotationPlugin) @Component({ @@ -64,6 +66,9 @@ export class PortfolioSummaryComponent implements OnInit { private readonly _unsubscribeAll$ = new Subject() @ViewChild('canvas') canvas!: ElementRef private _userService = inject(UserService) + private _router = inject(Router) + private _salesforcePortfolioService = inject(SalesforcePortfolioService) + isLoggedIntoBbSalesforce: boolean goals: Goal[] currentGoal: Goal @@ -102,12 +107,19 @@ export class PortfolioSummaryComponent implements OnInit { ] ngOnInit(): void { + this._organizationService.currentOrganization$.pipe( + takeUntil(this._unsubscribeAll$), + switchMap((organization) => { + this.organization = organization + return this._salesforcePortfolioService.verifyToken(this.organization.id) + }) + ).subscribe((r) => { + this.isLoggedIntoBbSalesforce = r.valid + }) + this._goalService.goals$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((goals) => { this.goals = goals }) - this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { - this.organization = organization - }) this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { this.currentUser = currentUser }) @@ -115,11 +127,15 @@ export class PortfolioSummaryComponent implements OnInit { // Dialog openers openAddCycle(): void { - this._matDialog.open(AddCycleDialogComponent, { + const dialogRef = this._matDialog.open(AddCycleDialogComponent, { autoFocus: false, disableClose: true, - data: {} satisfies AddCycleData, + data: {currentGoal: this.currentGoal, isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce} satisfies AddCycleData, }) + + dialogRef.afterClosed().subscribe((newCycleGoal) => { + if (newCycleGoal) this.currentGoal.cycle_goals.push(newCycleGoal) + }); } openConfigureGoals(): void { @@ -128,7 +144,7 @@ export class PortfolioSummaryComponent implements OnInit { disableClose: true, width: '50rem', height: '50rem', - data: { goals: this.goals } satisfies ConfigureGoalsData, + data: { goals: this.goals, isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce, bb_salesforce_enabled: this.organization.bb_salesforce_enabled } satisfies ConfigureGoalsData, }) } @@ -181,6 +197,20 @@ export class PortfolioSummaryComponent implements OnInit { }) } + toSettings() { + this._router.navigate(['organizations/settings/salesforce-portfolio-integration']) + } + + loginToSalesforce(): void { + this._salesforcePortfolioService + .getLoginUrl(this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((response) => { + console.log(response) + window.location.href = response.url; + }) + } + // Selectors selectGoal(event: MatSelectChange) { const goalId: number = event.value as number diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts index 5062ad05..9bfcc5b3 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts @@ -2,6 +2,12 @@ import type { Goal } from '@seed/api/goal' export type ConfigureGoalsData = { goals: Goal[]; + isLoggedIntoBbSalesforce: boolean; + bb_salesforce_enabled: boolean; + } -export type AddCycleData = object +export type AddCycleData = { + currentGoal: Goal + isLoggedIntoBbSalesforce: boolean +} diff --git a/src/app/modules/organizations/settings/salesforce/modal/delete-modal.component.html b/src/app/modules/organizations/settings/salesforce-building-integration/modal/delete-modal.component.html similarity index 100% rename from src/app/modules/organizations/settings/salesforce/modal/delete-modal.component.html rename to src/app/modules/organizations/settings/salesforce-building-integration/modal/delete-modal.component.html diff --git a/src/app/modules/organizations/settings/salesforce/modal/delete-modal.component.ts b/src/app/modules/organizations/settings/salesforce-building-integration/modal/delete-modal.component.ts similarity index 100% rename from src/app/modules/organizations/settings/salesforce/modal/delete-modal.component.ts rename to src/app/modules/organizations/settings/salesforce-building-integration/modal/delete-modal.component.ts diff --git a/src/app/modules/organizations/settings/salesforce/modal/form-modal.component.html b/src/app/modules/organizations/settings/salesforce-building-integration/modal/form-modal.component.html similarity index 100% rename from src/app/modules/organizations/settings/salesforce/modal/form-modal.component.html rename to src/app/modules/organizations/settings/salesforce-building-integration/modal/form-modal.component.html diff --git a/src/app/modules/organizations/settings/salesforce/modal/form-modal.component.ts b/src/app/modules/organizations/settings/salesforce-building-integration/modal/form-modal.component.ts similarity index 100% rename from src/app/modules/organizations/settings/salesforce/modal/form-modal.component.ts rename to src/app/modules/organizations/settings/salesforce-building-integration/modal/form-modal.component.ts diff --git a/src/app/modules/organizations/settings/salesforce/modal/index.ts b/src/app/modules/organizations/settings/salesforce-building-integration/modal/index.ts similarity index 100% rename from src/app/modules/organizations/settings/salesforce/modal/index.ts rename to src/app/modules/organizations/settings/salesforce-building-integration/modal/index.ts diff --git a/src/app/modules/organizations/settings/salesforce/salesforce.component.html b/src/app/modules/organizations/settings/salesforce-building-integration/salesforce-building-integration.component.html similarity index 99% rename from src/app/modules/organizations/settings/salesforce/salesforce.component.html rename to src/app/modules/organizations/settings/salesforce-building-integration/salesforce-building-integration.component.html index dcc11295..9cef3b0a 100644 --- a/src/app/modules/organizations/settings/salesforce/salesforce.component.html +++ b/src/app/modules/organizations/settings/salesforce-building-integration/salesforce-building-integration.component.html @@ -1,5 +1,10 @@ @if (organization) {
diff --git a/src/app/modules/organizations/settings/salesforce/salesforce.component.ts b/src/app/modules/organizations/settings/salesforce-building-integration/salesforce-building-integration.component.ts similarity index 97% rename from src/app/modules/organizations/settings/salesforce/salesforce.component.ts rename to src/app/modules/organizations/settings/salesforce-building-integration/salesforce-building-integration.component.ts index f00de1f9..fe7b187b 100644 --- a/src/app/modules/organizations/settings/salesforce/salesforce.component.ts +++ b/src/app/modules/organizations/settings/salesforce-building-integration/salesforce-building-integration.component.ts @@ -26,8 +26,8 @@ import { naturalSort } from '@seed/utils' import { DeleteModalComponent, FormModalComponent } from './modal' @Component({ - selector: 'seed-organizations-settings-salesforce', - templateUrl: './salesforce.component.html', + selector: 'seed-organizations-settings-salesforce-building-integration', + templateUrl: './salesforce-building-integration.component.html', imports: [ MatButtonModule, MatDividerModule, @@ -43,7 +43,7 @@ import { DeleteModalComponent, FormModalComponent } from './modal' SharedImports, ], }) -export class SalesforceComponent implements OnDestroy, OnInit { +export class SalesforceBuildingIntegrationComponent implements OnDestroy, OnInit { private _organizationService = inject(OrganizationService) private _salesforceService = inject(SalesforceService) private _labelService = inject(LabelService) diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html new file mode 100644 index 00000000..1d10c10b --- /dev/null +++ b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html @@ -0,0 +1,72 @@ + +
+
+
+ +
Enable this if you would like to sync data between SEED and a Salesforce instance
+
+ {{ t('Enable Salesforce Integration') }} +
+ + @if (salesforceForm.value.enabled) { +
+ + + {{ t('URL') }} + + + + + {{ t('Client ID') }} + + + + + {{ t('Client Secret') }} + + + +
+ + +
+ +
+ } +
+ +
+ Salesforce Connection Status: + @if (isLoggedIntoBbSalesforce) { + check_circle + + } @else { + cancel + + } +
+
+
+
diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.scss b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.spec.ts b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.spec.ts new file mode 100644 index 00000000..e0aaeb4f --- /dev/null +++ b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.spec.ts @@ -0,0 +1,22 @@ +import type { ComponentFixture } from '@angular/core/testing' +import { TestBed } from '@angular/core/testing' +import { SalesforcePortfolioIntegrationComponent } from './salesforce-portfolio-integration.component' + +describe('SalesforcePortfolioIntegrationComponent', () => { + let component: SalesforcePortfolioIntegrationComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SalesforcePortfolioIntegrationComponent], + }).compileComponents() + + fixture = TestBed.createComponent(SalesforcePortfolioIntegrationComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts new file mode 100644 index 00000000..f841fa90 --- /dev/null +++ b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts @@ -0,0 +1,117 @@ +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatSlideToggleModule } from '@angular/material/slide-toggle' +import { Subject, takeUntil } from 'rxjs' +import type { Organization } from '@seed/api/organization' +import { OrganizationService } from '@seed/api/organization' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' +import { PageComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { MatButtonModule } from '@angular/material/button' +import {Router} from '@angular/router'; + +@Component({ + selector: 'seed-salesforce-portfolio-integration', + imports: [MatButtonModule, PageComponent, SharedImports, ReactiveFormsModule, MatInputModule, MatIconModule, MatSlideToggleModule, MatFormFieldModule], + templateUrl: './salesforce-portfolio-integration.component.html', + styleUrl: './salesforce-portfolio-integration.component.scss', +}) +export class SalesforcePortfolioIntegrationComponent implements OnInit { + salesforceForm = new FormGroup({ + enabled: new FormControl(false), + config: new FormGroup({ + url: new FormControl(''), + clientId: new FormControl(''), + clientSecret: new FormControl(''), + }), + }) + passwordHidden = true + private _organizationService = inject(OrganizationService) + organization: Organization + private readonly _unsubscribeAll$ = new Subject() + private _salesforcePortfolioService = inject(SalesforcePortfolioService) + private router = inject(Router); + isLoggedIntoBbSalesforce: boolean + + ngOnInit(): void { + const config = this.salesforceForm.get('config') + this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { + this.organization = organization + + this.salesforceForm.get('enabled').setValue(this.organization.bb_salesforce_enabled) + + if (this.organization.bb_salesforce_enabled) config.enable() + else config.disable() + + this._salesforcePortfolioService + .getConfig(this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((_config) => { + config.setValue({ + url: _config.salesforce_url ?? '', + clientId: _config.client_id ?? '', + clientSecret: _config.client_secret ?? '', + }) + }) + + this._salesforcePortfolioService + .verifyToken(this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((response) => { + this.isLoggedIntoBbSalesforce = response.valid + console.log(response) + }) + }) + } + + submit(): void { + const config = this.salesforceForm.get('config') + console.log(config.value) + this._salesforcePortfolioService + .updateConfig( + { + salesforce_url: config.value.url, + client_id: config.value.clientId, + client_secret: config.value.clientSecret, + }, + this.organization.id, + ) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((_config) => { + config.setValue({ + url: _config.salesforce_url ?? '', + clientId: _config.client_id ?? '', + clientSecret: _config.client_secret ?? '', + }) + }) + } + + togglePassword(): void { + this.passwordHidden = !this.passwordHidden + } + + loginToSalesforce(): void { + this._salesforcePortfolioService + .getLoginUrl(this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((response) => { + console.log(response) + window.location.href = response.url; + }) + } + + toggleForm(): void { + const enabled = this.salesforceForm.get('enabled').value + + this.organization.bb_salesforce_enabled = enabled + this._organizationService.updateSettings(this.organization).subscribe() + + const config = this.salesforceForm.get('config') + if (enabled) config.enable() + else config.disable() + } +} diff --git a/src/app/modules/organizations/settings/settings.component.ts b/src/app/modules/organizations/settings/settings.component.ts index 417b8da1..fca03bc2 100644 --- a/src/app/modules/organizations/settings/settings.component.ts +++ b/src/app/modules/organizations/settings/settings.component.ts @@ -62,9 +62,15 @@ export class SettingsComponent implements AfterViewInit { type: 'basic', }, { - id: 'organizations/settings/salesforce', - link: '/organizations/settings/salesforce', - title: 'Salesforce Integration', + id: 'organizations/settings/salesforce-building-integration', + link: '/organizations/settings/salesforce-building-integration', + title: 'Salesforce Building Integration', + type: 'basic', + }, + { + id: 'organizations/settings/salesforce-portfolio-integration', + link: '/organizations/settings/salesforce-portfolio-integration', + title: 'Salesforce Portfolio Integration', type: 'basic', }, { diff --git a/src/app/modules/organizations/settings/settings.routes.ts b/src/app/modules/organizations/settings/settings.routes.ts index af9f3955..7ed10f6f 100644 --- a/src/app/modules/organizations/settings/settings.routes.ts +++ b/src/app/modules/organizations/settings/settings.routes.ts @@ -6,7 +6,8 @@ import { DisplayUnitsComponent } from './display-units/display-units.component' import { EmailComponent } from './email/email.component' import { MaintenanceComponent } from './maintenance/maintenance.component' import { OptionsComponent } from './options/options.component' -import { SalesforceComponent } from './salesforce/salesforce.component' +import { SalesforceBuildingIntegrationComponent } from './salesforce-building-integration/salesforce-building-integration.component' +import { SalesforcePortfolioIntegrationComponent } from './salesforce-portfolio-integration/salesforce-portfolio-integration.component' import { TwoFactorComponent } from './two-factor/two-factor.component' import { UBIDComponent } from './ubid/ubid.component' @@ -52,9 +53,14 @@ export default [ component: MaintenanceComponent, }, { - path: 'salesforce', - title: 'Salesforce Integration', - component: SalesforceComponent, + path: 'salesforce-building-integration', + title: 'Salesforce Building Integration', + component: SalesforceBuildingIntegrationComponent, + }, + { + path: 'salesforce-portfolio-integration', + title: 'Salesforce Portfolio Integration', + component: SalesforcePortfolioIntegrationComponent, }, { path: 'two-factor', diff --git a/src/app/modules/salesforce-login/salesforce-login.component.html b/src/app/modules/salesforce-login/salesforce-login.component.html new file mode 100644 index 00000000..e4165d65 --- /dev/null +++ b/src/app/modules/salesforce-login/salesforce-login.component.html @@ -0,0 +1 @@ +

salesforce-login works!

diff --git a/src/app/modules/salesforce-login/salesforce-login.component.scss b/src/app/modules/salesforce-login/salesforce-login.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/salesforce-login/salesforce-login.component.spec.ts b/src/app/modules/salesforce-login/salesforce-login.component.spec.ts new file mode 100644 index 00000000..b65e6a08 --- /dev/null +++ b/src/app/modules/salesforce-login/salesforce-login.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SalesforceLoginComponent } from './salesforce-login.component'; + +describe('SalesforceLoginComponent', () => { + let component: SalesforceLoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SalesforceLoginComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SalesforceLoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/modules/salesforce-login/salesforce-login.component.ts b/src/app/modules/salesforce-login/salesforce-login.component.ts new file mode 100644 index 00000000..22f1f125 --- /dev/null +++ b/src/app/modules/salesforce-login/salesforce-login.component.ts @@ -0,0 +1,34 @@ +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' +import { forkJoin, Subject, switchMap, take, takeUntil, tap, combineLatest, map} from 'rxjs' +import { OrganizationService } from '@seed/api/organization' + +@Component({ + selector: 'app-salesforce-login', + imports: [], + templateUrl: './salesforce-login.component.html', + styleUrl: './salesforce-login.component.scss' +}) +export class SalesforceLoginComponent { + private _route = inject(ActivatedRoute) + private _salesforcePortfolioService = inject(SalesforcePortfolioService) + private _organizationService = inject(OrganizationService) + private readonly _unsubscribeAll$ = new Subject() + + ngOnInit() { + combineLatest([ + this._route.queryParams, + this._organizationService.currentOrganization$, + ]).pipe( + takeUntil(this._unsubscribeAll$), + switchMap(([params, organization]) => { + console.log("params", params.code) + console.log("organization", organization.id) + return this._salesforcePortfolioService.getToken(params.code, organization.id) + }) + ).subscribe((r) => { + console.log("final: ", r) + }) + } +} From 6b40d305e112164415763172f68e76f160342f65 Mon Sep 17 00:00:00 2001 From: heslinge Date: Fri, 6 Mar 2026 16:21:11 -0700 Subject: [PATCH 6/6] Lint --- src/@seed/api/goal/goal.service.ts | 16 ++--- .../salesforce-portfolio.service.ts | 11 +++- .../salesforce-portfolio.types.ts | 3 +- .../add-cycle-dialog.component.html | 15 +++-- .../add-cycle-dialog.component.ts | 41 ++++++------- .../configure-goals-dialog.component.html | 56 +++++++++-------- .../configure-goals-dialog.component.ts | 60 ++++++++++--------- .../portfolio-summary.component.html | 13 ++-- .../portfolio-summary.component.ts | 47 ++++++++------- .../portfolio-summary.types.ts | 5 +- ...force-portfolio-integration.component.html | 20 +++---- ...force-portfolio-integration.component.scss | 0 ...esforce-portfolio-integration.component.ts | 33 ++++++---- .../salesforce-login.component.scss | 0 .../salesforce-login.component.spec.ts | 29 +++++---- .../salesforce-login.component.ts | 36 ++++++----- 16 files changed, 202 insertions(+), 183 deletions(-) delete mode 100644 src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.scss delete mode 100644 src/app/modules/salesforce-login/salesforce-login.component.scss diff --git a/src/@seed/api/goal/goal.service.ts b/src/@seed/api/goal/goal.service.ts index 0df43ee3..dffbc4cf 100644 --- a/src/@seed/api/goal/goal.service.ts +++ b/src/@seed/api/goal/goal.service.ts @@ -86,14 +86,16 @@ export class GoalService { createCycleGoal(goalId: number, cycleId: number, annual_report_id: string, annual_report_name: string): Observable { const url = `/api/v3/goals/${goalId}/cycles/?organization_id=${this.orgId}` - return this._httpClient.post(url, { + return this._httpClient + .post(url, { current_cycle: cycleId, salesforce_annual_report_id: annual_report_id, - salesforce_annual_report_name: annual_report_name - }).pipe( - catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) - }), - ) + salesforce_annual_report_name: annual_report_name, + }) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, `Error fetching summary: ${error.message}`) + }), + ) } } diff --git a/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts index f8e3e0e7..7bee5d5a 100644 --- a/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts +++ b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts @@ -2,10 +2,17 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { catchError, map, takeUntil, tap, Subject } from 'rxjs' +import { catchError, map, Subject, takeUntil, tap } from 'rxjs' import { ErrorService } from '@seed/services' -import type { SalesforcePortfolioConfig, SalesforcePortfolioConfigResponse, verifyTokenResponse, loginUrlResponse, getTokenResponse, getPartnersResponse } from './salesforce-portfolio.types' import { UserService } from '../user' +import type { + getPartnersResponse, + getTokenResponse, + loginUrlResponse, + SalesforcePortfolioConfig, + SalesforcePortfolioConfigResponse, + verifyTokenResponse, +} from './salesforce-portfolio.types' @Injectable({ providedIn: 'root', diff --git a/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts index f0ad45cf..175cc196 100644 --- a/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts +++ b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts @@ -37,7 +37,6 @@ export type getPartnersResponse = { results: SalesforcePartner[]; } - export type AnnualReport = { id: string; name: string; @@ -52,4 +51,4 @@ export type loginUrlResponse = { status: string; message?: string; url?: string; -} \ No newline at end of file +} diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html index 3884ce2b..c6c04ab0 100644 --- a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html @@ -3,7 +3,13 @@
{{ t('Add Cycle') }}
-
{{ t('Add a new cycle to the selected goal to compare against the baseline. The latest cycle added will be considered the "current" cycle for comparison purposes. Older cycles added will remain available for historical reference.') }}
+
+ {{ + t( + 'Add a new cycle to the selected goal to compare against the baseline. The latest cycle added will be considered the "current" cycle for comparison purposes. Older cycles added will remain available for historical reference.' + ) + }} +
@@ -20,16 +26,15 @@
-
Annual Report @for (annualReport of annualReports; track annualReport.id) { {{ annualReport.name }} @@ -37,12 +42,12 @@
- @if (!isLoggedIntoBbSalesforce){ + @if (!isLoggedIntoBbSalesforce) {
{{ t('You must be logged in to salesforce to select a Annual Report') }}
}
- +
diff --git a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts index ac9dcd89..df715ac5 100644 --- a/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts @@ -1,24 +1,22 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' -import { inject } from '@angular/core' -import { Component, ViewEncapsulation } from '@angular/core' +import { Component, inject, ViewEncapsulation } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' -import { Subject } from 'rxjs' -import { SharedImports } from '@seed/directives' -import { type Cycle, CycleService } from '@seed/api/cycle' -import { type Goal, GoalService } from '@seed/api/goal' -import { catchError, combineLatest, map, of, ReplaySubject, switchMap, tap, takeUntil } from 'rxjs' import type { MatSelectChange } from '@angular/material/select' import { MatSelectModule } from '@angular/material/select' -import { AnnualReport, SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' -import { MAT_DIALOG_DATA } from '@angular/material/dialog' -import type { AddCycleData, ConfigureGoalsData } from '../portfolio-summary.types' +import { Subject, takeUntil } from 'rxjs' +import { type Cycle, CycleService } from '@seed/api/cycle' +import { GoalService } from '@seed/api/goal' +import type { AnnualReport } from '@seed/api/salesforce-portfolio' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' +import { SharedImports } from '@seed/directives' +import type { AddCycleData } from '../portfolio-summary.types' @Component({ selector: 'seed-add-cycle-dialog', @@ -44,8 +42,8 @@ export class AddCycleDialogComponent implements OnInit, OnDestroy { private _goalService = inject(GoalService) private _salesforcePortfolioService = inject(SalesforcePortfolioService) cycles: Cycle[] = [] - selectedCycle?: Cycle = null; - selectedAnnualReport?: AnnualReport = null; + selectedCycle?: Cycle = null + selectedAnnualReport?: AnnualReport = null data = inject(MAT_DIALOG_DATA) as AddCycleData isLoggedIntoBbSalesforce: boolean annualReports: AnnualReport[] = [] @@ -58,7 +56,7 @@ export class AddCycleDialogComponent implements OnInit, OnDestroy { this.isLoggedIntoBbSalesforce = this.data.isLoggedIntoBbSalesforce if (this.isLoggedIntoBbSalesforce) { - this._salesforcePortfolioService.getAnnualReports(this.data.currentGoal.id).subscribe(annualReports => { + this._salesforcePortfolioService.getAnnualReports(this.data.currentGoal.id).subscribe((annualReports) => { this.annualReports = annualReports.results }) } @@ -88,14 +86,11 @@ export class AddCycleDialogComponent implements OnInit, OnDestroy { submit(): void { console.log(this.selectedAnnualReport) console.log(this.selectedCycle) - this._goalService.createCycleGoal( - this.data.currentGoal.id, - this.selectedCycle.id, - this.selectedAnnualReport.id, - this.selectedAnnualReport.name, - ).subscribe(newCycleGoal => { - console.log(newCycleGoal) - this._dialogRef.close(newCycleGoal) - }) + this._goalService + .createCycleGoal(this.data.currentGoal.id, this.selectedCycle.id, this.selectedAnnualReport.id, this.selectedAnnualReport.name) + .subscribe((newCycleGoal) => { + console.log(newCycleGoal) + this._dialogRef.close(newCycleGoal) + }) } } diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html index dc0875e4..fc3e20d4 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html @@ -137,37 +137,35 @@ {{ t('Committed Area') }} - - @if (bb_salesforce_enabled) { -
{{ t('SALESFORCE') }}
- @if (isLoggedIntoBbSalesforce) { + + @if (bb_salesforce_enabled) { +
{{ t('SALESFORCE') }}
+ @if (isLoggedIntoBbSalesforce) { + + Salesforce Partner + + -- + @for (partner of salesforcePartners; track partner.id) { + {{ partner.name }} + } + + - - Salesforce Partner - - -- - @for (partner of salesforcePartners; track partner.id) { - {{ partner.name }} - } - - - - - Salesforce Goals - - @for (salesforceGoal of salesforceGoals; track salesforceGoal.id) { - {{ salesforceGoal.name }} - } - - - - } @else{ -
- -

{{ t('Log in to Salesforce first to be able to configure Salesforce Partner and Goal associations here.')}}

-
+ + Salesforce Goals + + @for (salesforceGoal of salesforceGoals; track salesforceGoal.id) { + {{ salesforceGoal.name }} + } + + + } @else { +
+ +

{{ t('Log in to Salesforce first to be able to configure Salesforce Partner and Goal associations here.') }}

+
+ } } - }
diff --git a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts index 506d7a3a..3618ba86 100644 --- a/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts @@ -10,7 +10,7 @@ import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' import { MatSelectModule } from '@angular/material/select' -import { Subject, takeUntil, combineLatest, switchMap } from 'rxjs' +import { combineLatest, Subject, switchMap, takeUntil } from 'rxjs' import type { Column } from '@seed/api/column' import { ColumnService } from '@seed/api/column' import { type Cycle, CycleService } from '@seed/api/cycle' @@ -18,10 +18,10 @@ import type { Goal } from '@seed/api/goal' import { GoalService } from '@seed/api/goal' import type { AccessLevelInstancesByDepth, AccessLevelsByDepth, Organization } from '@seed/api/organization' import { OrganizationService } from '@seed/api/organization' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' +import type { SalesforceGoal, SalesforcePartner } from '@seed/api/salesforce-portfolio/salesforce-portfolio.types' import { SharedImports } from '@seed/directives' import type { ConfigureGoalsData } from '../portfolio-summary.types' -import { SalesforcePartner, SalesforceGoal } from '@seed/api/salesforce-portfolio/salesforce-portfolio.types' -import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' @Component({ selector: 'seed-configure-goals-dialog', @@ -76,42 +76,45 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { isLoggedIntoBbSalesforce: boolean bb_salesforce_enabled: boolean private _salesforcePortfolioService = inject(SalesforcePortfolioService) - salesforcePartners: SalesforcePartner[]; - salesforceGoals: SalesforceGoal[]; + salesforcePartners: SalesforcePartner[] + salesforceGoals: SalesforceGoal[] ngOnInit(): void { - this.isLoggedIntoBbSalesforce = this.data.isLoggedIntoBbSalesforce; - this.bb_salesforce_enabled = this.data.bb_salesforce_enabled; + this.isLoggedIntoBbSalesforce = this.data.isLoggedIntoBbSalesforce + this.bb_salesforce_enabled = this.data.bb_salesforce_enabled this.goals = this.data.goals - this._organizationService.currentOrganization$.pipe( - takeUntil(this._unsubscribeAll$), - switchMap((organization) => { - this.organization = organization - return this._salesforcePortfolioService.getPartners(this.organization.id) - }) - ).subscribe((r) => { - this.salesforcePartners = r.results; - }) + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + switchMap((organization) => { + this.organization = organization + return this._salesforcePortfolioService.getPartners(this.organization.id) + }), + ) + .subscribe((r) => { + this.salesforcePartners = r.results + }) combineLatest([ this._cycleService.cycles$, this._organizationService.accessLevelTree$, this._organizationService.accessLevelInstancesByDepth$, this._columnService.propertyColumns$, - ]).pipe(takeUntil(this._unsubscribeAll$)) - .subscribe(([cycles, {accessLevelNames}, accessLevelsByDepth, propertyColumns]) => { - this.cycles = cycles - this.accessLevelNames = accessLevelNames - this.accessLevelInstancesByDepth = accessLevelsByDepth - this.areaColumns = propertyColumns.filter((c) => c.data_type == 'area') - this.euiColumns = propertyColumns.filter((c) => c.data_type == 'eui') - }) + ]) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe(([cycles, { accessLevelNames }, accessLevelsByDepth, propertyColumns]) => { + this.cycles = cycles + this.accessLevelNames = accessLevelNames + this.accessLevelInstancesByDepth = accessLevelsByDepth + this.areaColumns = propertyColumns.filter((c) => c.data_type == 'area') + this.euiColumns = propertyColumns.filter((c) => c.data_type == 'eui') + }) if (this.goals.length > 0) { this.currentGoal = this.goals[0] this.selectGoal(this.goals[0].id) - } + } } selectGoal(goalId?: number) { @@ -148,15 +151,14 @@ export class ConfigureGoalsDialogComponent implements OnInit, OnDestroy { } onPartnerChange(partnerId: string) { - const partner = this.salesforcePartners.find(p => p.id == partnerId) + const partner = this.salesforcePartners.find((p) => p.id == partnerId) this.salesforceGoals = partner.goals } - save(): void { const formValues = this.goalForm.value - const partner = this.salesforcePartners.find(p => p.id == formValues.salesforcePartnerID) - const goal = partner.goals.find(g => g.id == formValues.salesforceGoalID) + const partner = this.salesforcePartners.find((p) => p.id == formValues.salesforcePartnerID) + const goal = partner.goals.find((g) => g.id == formValues.salesforceGoalID) const request_data = { name: formValues.name, diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html index a892785f..d29186d6 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html @@ -29,21 +29,24 @@
@if (organization && organization.bb_salesforce_enabled) { -

Salesforce portfolio functionality is enabled for this organization. Configure connection details by clicking on

- {{ t('Salesforce settings') }} +

Salesforce portfolio functionality is enabled for this organization. Configure connection details by clicking on

+ {{ t('Salesforce settings') }}
Salesforce Connection Status: @if (isLoggedIntoBbSalesforce) { check_circle - + } @else { cancel - + }
} - @if (currentGoal) {
diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts index efb6504e..7a1b7716 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts @@ -13,16 +13,17 @@ import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import type { MatSelectChange } from '@angular/material/select' import { MatSelectModule } from '@angular/material/select' -import { RouterLink } from '@angular/router' +import { Router, RouterLink } from '@angular/router' import { AgGridAngular, AgGridModule } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' import { Chart } from 'chart.js/auto' import annotationPlugin from 'chartjs-plugin-annotation' -import { Subject, takeUntil, switchMap } from 'rxjs' +import { Subject, switchMap, takeUntil } from 'rxjs' import type { CycleGoal, Goal, PortfolioSummary, WeightedEUI } from '@seed/api/goal' import { GoalService } from '@seed/api/goal' import type { Organization } from '@seed/api/organization' import { OrganizationService } from '@seed/api/organization' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' import { NotFoundComponent, PageComponent } from '@seed/components' @@ -31,8 +32,6 @@ import { ConfigService } from '@seed/services' import { AddCycleDialogComponent } from './add-cycle-dialog' import { ConfigureGoalsDialogComponent } from './configure-goals-dialog' import type { AddCycleData, ConfigureGoalsData } from './portfolio-summary.types' -import { Router } from '@angular/router' -import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' Chart.register(annotationPlugin) @Component({ @@ -107,15 +106,17 @@ export class PortfolioSummaryComponent implements OnInit { ] ngOnInit(): void { - this._organizationService.currentOrganization$.pipe( - takeUntil(this._unsubscribeAll$), - switchMap((organization) => { - this.organization = organization - return this._salesforcePortfolioService.verifyToken(this.organization.id) - }) - ).subscribe((r) => { - this.isLoggedIntoBbSalesforce = r.valid - }) + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + switchMap((organization) => { + this.organization = organization + return this._salesforcePortfolioService.verifyToken(this.organization.id) + }), + ) + .subscribe((r) => { + this.isLoggedIntoBbSalesforce = r.valid + }) this._goalService.goals$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((goals) => { this.goals = goals @@ -130,12 +131,12 @@ export class PortfolioSummaryComponent implements OnInit { const dialogRef = this._matDialog.open(AddCycleDialogComponent, { autoFocus: false, disableClose: true, - data: {currentGoal: this.currentGoal, isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce} satisfies AddCycleData, + data: { currentGoal: this.currentGoal, isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce } satisfies AddCycleData, }) - dialogRef.afterClosed().subscribe((newCycleGoal) => { + dialogRef.afterClosed().subscribe((newCycleGoal?: CycleGoal) => { if (newCycleGoal) this.currentGoal.cycle_goals.push(newCycleGoal) - }); + }) } openConfigureGoals(): void { @@ -144,7 +145,11 @@ export class PortfolioSummaryComponent implements OnInit { disableClose: true, width: '50rem', height: '50rem', - data: { goals: this.goals, isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce, bb_salesforce_enabled: this.organization.bb_salesforce_enabled } satisfies ConfigureGoalsData, + data: { + goals: this.goals, + isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce, + bb_salesforce_enabled: this.organization.bb_salesforce_enabled, + } satisfies ConfigureGoalsData, }) } @@ -197,8 +202,8 @@ export class PortfolioSummaryComponent implements OnInit { }) } - toSettings() { - this._router.navigate(['organizations/settings/salesforce-portfolio-integration']) + async toSettings() { + await this._router.navigate(['organizations/settings/salesforce-portfolio-integration']) } loginToSalesforce(): void { @@ -207,8 +212,8 @@ export class PortfolioSummaryComponent implements OnInit { .pipe(takeUntil(this._unsubscribeAll$)) .subscribe((response) => { console.log(response) - window.location.href = response.url; - }) + window.location.href = response.url + }) } // Selectors diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts index 9bfcc5b3..e50374c8 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts @@ -4,10 +4,9 @@ export type ConfigureGoalsData = { goals: Goal[]; isLoggedIntoBbSalesforce: boolean; bb_salesforce_enabled: boolean; - } export type AddCycleData = { - currentGoal: Goal - isLoggedIntoBbSalesforce: boolean + currentGoal: Goal; + isLoggedIntoBbSalesforce: boolean; } diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html index 1d10c10b..7b30a398 100644 --- a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html +++ b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.html @@ -7,12 +7,8 @@ }" >
-
-
+
+
Enable this if you would like to sync data between SEED and a Salesforce instance
@@ -60,11 +56,15 @@
Salesforce Connection Status: @if (isLoggedIntoBbSalesforce) { - check_circle - + check_circle + } @else { - cancel - + cancel + }
diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.scss b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts index f841fa90..2463c194 100644 --- a/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts +++ b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts @@ -1,6 +1,7 @@ import type { OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' @@ -11,12 +12,19 @@ import { OrganizationService } from '@seed/api/organization' import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' import { PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' -import { MatButtonModule } from '@angular/material/button' -import {Router} from '@angular/router'; @Component({ selector: 'seed-salesforce-portfolio-integration', - imports: [MatButtonModule, PageComponent, SharedImports, ReactiveFormsModule, MatInputModule, MatIconModule, MatSlideToggleModule, MatFormFieldModule], + imports: [ + MatButtonModule, + PageComponent, + SharedImports, + ReactiveFormsModule, + MatInputModule, + MatIconModule, + MatSlideToggleModule, + MatFormFieldModule, + ], templateUrl: './salesforce-portfolio-integration.component.html', styleUrl: './salesforce-portfolio-integration.component.scss', }) @@ -34,7 +42,6 @@ export class SalesforcePortfolioIntegrationComponent implements OnInit { organization: Organization private readonly _unsubscribeAll$ = new Subject() private _salesforcePortfolioService = inject(SalesforcePortfolioService) - private router = inject(Router); isLoggedIntoBbSalesforce: boolean ngOnInit(): void { @@ -59,12 +66,12 @@ export class SalesforcePortfolioIntegrationComponent implements OnInit { }) this._salesforcePortfolioService - .verifyToken(this.organization.id) - .pipe(takeUntil(this._unsubscribeAll$)) - .subscribe((response) => { - this.isLoggedIntoBbSalesforce = response.valid - console.log(response) - }) + .verifyToken(this.organization.id) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe((response) => { + this.isLoggedIntoBbSalesforce = response.valid + console.log(response) + }) }) } @@ -93,15 +100,15 @@ export class SalesforcePortfolioIntegrationComponent implements OnInit { togglePassword(): void { this.passwordHidden = !this.passwordHidden } - + loginToSalesforce(): void { this._salesforcePortfolioService .getLoginUrl(this.organization.id) .pipe(takeUntil(this._unsubscribeAll$)) .subscribe((response) => { console.log(response) - window.location.href = response.url; - }) + window.location.href = response.url + }) } toggleForm(): void { diff --git a/src/app/modules/salesforce-login/salesforce-login.component.scss b/src/app/modules/salesforce-login/salesforce-login.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/modules/salesforce-login/salesforce-login.component.spec.ts b/src/app/modules/salesforce-login/salesforce-login.component.spec.ts index b65e6a08..073cc526 100644 --- a/src/app/modules/salesforce-login/salesforce-login.component.spec.ts +++ b/src/app/modules/salesforce-login/salesforce-login.component.spec.ts @@ -1,23 +1,22 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SalesforceLoginComponent } from './salesforce-login.component'; +import type { ComponentFixture } from '@angular/core/testing' +import { TestBed } from '@angular/core/testing' +import { SalesforceLoginComponent } from './salesforce-login.component' describe('SalesforceLoginComponent', () => { - let component: SalesforceLoginComponent; - let fixture: ComponentFixture; + let component: SalesforceLoginComponent + let fixture: ComponentFixture beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SalesforceLoginComponent] - }) - .compileComponents(); + imports: [SalesforceLoginComponent], + }).compileComponents() - fixture = TestBed.createComponent(SalesforceLoginComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(SalesforceLoginComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) it('should create', () => { - expect(component).toBeTruthy(); - }); -}); + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/modules/salesforce-login/salesforce-login.component.ts b/src/app/modules/salesforce-login/salesforce-login.component.ts index 22f1f125..d4bddb80 100644 --- a/src/app/modules/salesforce-login/salesforce-login.component.ts +++ b/src/app/modules/salesforce-login/salesforce-login.component.ts @@ -1,34 +1,32 @@ -import { Component, inject } from '@angular/core'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router' -import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' -import { forkJoin, Subject, switchMap, take, takeUntil, tap, combineLatest, map} from 'rxjs' +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { combineLatest, Subject, switchMap, takeUntil } from 'rxjs' import { OrganizationService } from '@seed/api/organization' +import { SalesforcePortfolioService } from '@seed/api/salesforce-portfolio' @Component({ - selector: 'app-salesforce-login', + selector: 'seed-salesforce-login', imports: [], templateUrl: './salesforce-login.component.html', - styleUrl: './salesforce-login.component.scss' + styleUrl: './salesforce-login.component.scss', }) -export class SalesforceLoginComponent { +export class SalesforceLoginComponent implements OnInit { private _route = inject(ActivatedRoute) private _salesforcePortfolioService = inject(SalesforcePortfolioService) private _organizationService = inject(OrganizationService) private readonly _unsubscribeAll$ = new Subject() ngOnInit() { - combineLatest([ - this._route.queryParams, - this._organizationService.currentOrganization$, - ]).pipe( - takeUntil(this._unsubscribeAll$), - switchMap(([params, organization]) => { - console.log("params", params.code) - console.log("organization", organization.id) - return this._salesforcePortfolioService.getToken(params.code, organization.id) + combineLatest([this._route.queryParams, this._organizationService.currentOrganization$]) + .pipe( + takeUntil(this._unsubscribeAll$), + switchMap(([params, organization]) => { + return this._salesforcePortfolioService.getToken(params.code as string, organization.id) + }), + ) + .subscribe((r) => { + console.log('final: ', r) }) - ).subscribe((r) => { - console.log("final: ", r) - }) } }