diff --git a/cspell.json b/cspell.json index 900e49ab..34e33edb 100644 --- a/cspell.json +++ b/cspell.json @@ -23,13 +23,16 @@ "CEJST", "eeej", "EPSG", + "euis", "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 new file mode 100644 index 00000000..dffbc4cf --- /dev/null +++ b/src/@seed/api/goal/goal.service.ts @@ -0,0 +1,101 @@ +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 { CycleGoal, Goal, GoalsResponse, PortfolioSummary, weightedEUIsResponse } 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) + this.orgId = 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() + } + + 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}`) + }), + ) + } + + 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}`) + }), + ) + } + + 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}`) + }), + ) + } + + 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}`) + }), + ) + } + + 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/goal/goal.types.ts b/src/@seed/api/goal/goal.types.ts new file mode 100644 index 00000000..be21187d --- /dev/null +++ b/src/@seed/api/goal/goal.types.ts @@ -0,0 +1,81 @@ +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?: number; + eui_column2_name?: string; + eui_column3?: number; + 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?: number; + salesforce_goal_id?: string; + salesforce_goal_name?: string; + salesforce_partner_id?: string; + salesforce_partner_name?: string; + target_percentage: number; + transactions_column?: string; + type: 'standard' | 'transaction'; + 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; +} + +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/@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/@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..7bee5d5a --- /dev/null +++ b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.service.ts @@ -0,0 +1,107 @@ +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, Subject, takeUntil, tap } from 'rxjs' +import { ErrorService } from '@seed/services' +import { UserService } from '../user' +import type { + getPartnersResponse, + getTokenResponse, + loginUrlResponse, + SalesforcePortfolioConfig, + SalesforcePortfolioConfigResponse, + verifyTokenResponse, +} from './salesforce-portfolio.types' + +@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..175cc196 --- /dev/null +++ b/src/@seed/api/salesforce-portfolio/salesforce-portfolio.types.ts @@ -0,0 +1,54 @@ +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; +} 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 new file mode 100644 index 00000000..c6c04ab0 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.html @@ -0,0 +1,53 @@ +
+ +
+
{{ 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 new file mode 100644 index 00000000..df715ac5 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/add-cycle-dialog/add-cycle-dialog.component.ts @@ -0,0 +1,96 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewEncapsulation } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +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 type { MatSelectChange } from '@angular/material/select' +import { MatSelectModule } from '@angular/material/select' +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', + templateUrl: './add-cycle-dialog.component.html', + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + 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 { + 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/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..fc3e20d4 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.html @@ -0,0 +1,177 @@ +
+ +
+
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') }} + + + + @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 new file mode 100644 index 00000000..3618ba86 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/configure-goals-dialog/configure-goals-dialog.component.ts @@ -0,0 +1,206 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewEncapsulation } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +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 { MatSelectModule } from '@angular/material/select' +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' +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' + +@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, + MatSelectModule, + 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), + salesforcePartnerID: new FormControl(null, Validators.required), + salesforceGoalID: 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 + 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._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') + }) + + 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.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, + 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] + } + + 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, + 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, + salesforce_partner_id: partner.id, + salesforce_partner_name: partner.name, + salesforce_goal_id: goal.id, + salesforce_goal_name: goal.name, + } + + 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 { + 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..d29186d6 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,196 @@ - -
Portfolio Summary Content
+ + +
+ +
+ + +
+ Goal: + + @for (goal of goals; track goal.id) { + {{ goal.name }} + } + +
+ + @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) { +
+ +
+
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){{ currentGoal.commitment_sqft }}
Shared (Sq. Ft){{ portfolioSummary.shared_sqft }}
Passing Checks (Sq. Ft){{ portfolioSummary.current_total_sqft }}
Passing Checks (% of committed){{ portfolioSummary.passing_committed }}
Passing Checks (% of shared){{ portfolioSummary.passing_shared }}
Total Passing Checks{{ portfolioSummary.total_passing }}
Total New or Acquired{{ portfolioSummary.total_new_or_acquired }}
+ } +
+ + + + @for (cycle_goal of currentGoal.cycle_goals; track cycle_goal.id) { + {{ cycle_goal.current_cycle.name }} + } + + + + + @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.') + }} +
+
+
+ + +
+ +
+ } + + +
+
+ + 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 }} +
+
+ +
+ }
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..7a1b7716 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,287 @@ -import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' -import { PageComponent } from '@seed/components' +import { CommonModule } from '@angular/common' +import type { ElementRef, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { FormControl, FormGroup, 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 { MatCheckboxModule } from '@angular/material/checkbox' +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 { 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, 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' +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' +Chart.register(annotationPlugin) @Component({ selector: 'seed-portfolio-summary', templateUrl: './portfolio-summary.component.html', - imports: [PageComponent], + imports: [ + CommonModule, + NotFoundComponent, + PageComponent, + MatIconModule, + MatButtonModule, + SharedImports, + MatSelectModule, + MatButtonToggleModule, + AgGridAngular, + AgGridModule, + MatExpansionModule, + RouterLink, + MatFormFieldModule, + FormsModule, + ReactiveFormsModule, + MatInputModule, + MatCheckboxModule, + ], }) 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() + @ViewChild('canvas') canvas!: ElementRef + private _userService = inject(UserService) + private _router = inject(Router) + private _salesforcePortfolioService = inject(SalesforcePortfolioService) + isLoggedIntoBbSalesforce: boolean + + goals: Goal[] + currentGoal: Goal + currentCycleGoal: CycleGoal + 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 } + 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' }, + { 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' }, + ] + goalSummaryData: WeightedEUI[] = [] + goalSummaryColumnDefs: ColDef[] = [ + { field: 'Cycle Name' }, + { field: 'Baseline?' }, + { field: 'EUI' }, + { field: 'Goal' }, + { field: 'Annual % Imp' }, + { field: 'Cumulative % Imp' }, + ] + ngOnInit(): void { - console.log('Portfolio Summary') + 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._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + } + + // Dialog openers + openAddCycle(): void { + const dialogRef = this._matDialog.open(AddCycleDialogComponent, { + autoFocus: false, + disableClose: true, + data: { currentGoal: this.currentGoal, isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce } satisfies AddCycleData, + }) + + dialogRef.afterClosed().subscribe((newCycleGoal?: CycleGoal) => { + if (newCycleGoal) this.currentGoal.cycle_goals.push(newCycleGoal) + }) + } + + openConfigureGoals(): void { + this._matDialog.open(ConfigureGoalsDialogComponent, { + autoFocus: false, + disableClose: true, + width: '50rem', + height: '50rem', + data: { + goals: this.goals, + isLoggedIntoBbSalesforce: this.isLoggedIntoBbSalesforce, + bb_salesforce_enabled: this.organization.bb_salesforce_enabled, + } satisfies ConfigureGoalsData, + }) + } + + // 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 + }) + } + + async toSettings() { + await 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 + 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) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe(({ results }) => { + this.createChart(results) + this.goalSummaryData = results + }) + } + + 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.cycleGoalSummaryData = [portfolioSummary] + this.portfolioSummary = portfolioSummary + }) + } + + // chart + createChart(weightedEUIs: WeightedEUI[]) { + 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], + }, + }, + }, + }, + }, + }) } } 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..e50374c8 --- /dev/null +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.types.ts @@ -0,0 +1,12 @@ +import type { Goal } from '@seed/api/goal' + +export type ConfigureGoalsData = { + goals: Goal[]; + isLoggedIntoBbSalesforce: boolean; + bb_salesforce_enabled: boolean; +} + +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..7b30a398 --- /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.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..2463c194 --- /dev/null +++ b/src/app/modules/organizations/settings/salesforce-portfolio-integration/salesforce-portfolio-integration.component.ts @@ -0,0 +1,124 @@ +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' +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' + +@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) + 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.spec.ts b/src/app/modules/salesforce-login/salesforce-login.component.spec.ts new file mode 100644 index 00000000..073cc526 --- /dev/null +++ b/src/app/modules/salesforce-login/salesforce-login.component.spec.ts @@ -0,0 +1,22 @@ +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 + + 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..d4bddb80 --- /dev/null +++ b/src/app/modules/salesforce-login/salesforce-login.component.ts @@ -0,0 +1,32 @@ +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: 'seed-salesforce-login', + imports: [], + templateUrl: './salesforce-login.component.html', + styleUrl: './salesforce-login.component.scss', +}) +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]) => { + return this._salesforcePortfolioService.getToken(params.code as string, organization.id) + }), + ) + .subscribe((r) => { + console.log('final: ', r) + }) + } +}