Skip to content

Commit 8989c9e

Browse files
Adds Program Overview and Property Insights (#55)
* program config * guard org mismatch on load inventory * program overview fxnal sans click events * property insights dev * property inisghts base * mark duplicates in data mapping * legend toggle visibility * click to detail * overview and insights unique components * remove program wrapper * insight ali fxnal * dev * limit duplicate network calls and timing * results and missing url id * form options update after program update * expandable data table * labels * ranked and sorted labels * form populate ali * set results, use getters * lint * visibility logic * Lint fixes * Fixes, added filter group, activate new program on save --------- Co-authored-by: Alex Swindler <Alex.Swindler@nrel.gov>
1 parent 600d3d9 commit 8989c9e

30 files changed

Lines changed: 2322 additions & 34 deletions

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
"@jsverse/transloco": "^7.5.1",
5252
"ag-grid-angular": "^33.1.1",
5353
"ag-grid-community": "^33.1.1",
54+
"chart.js": "^4.5.0",
55+
"chartjs-plugin-annotation": "^3.1.0",
56+
"chartjs-plugin-zoom": "^2.2.0",
5457
"crypto-es": "^2.1.0",
5558
"cspell": "^8.17.3",
5659
"file-saver": "^2.0.5",

pnpm-lock.yaml

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/@seed/api/dataset/dataset.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type DataMappingRow = {
6767
omit?: boolean; // optional, used for omitting columns
6868
isExtraData?: boolean; // used internally, not part of the API
6969
isNewColumn?: boolean; // used internally, not part of the API
70+
hasDuplicate?: boolean; // used internally, not part of the API
7071
}
7172

7273
export type MappedData = {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { HttpErrorResponse } from '@angular/common/http'
2+
import { HttpClient } from '@angular/common/http'
3+
import { inject, Injectable } from '@angular/core'
4+
import { BehaviorSubject, catchError, map, take, tap } from 'rxjs'
5+
import { ErrorService } from '@seed/services'
6+
import { naturalSort } from '@seed/utils'
7+
import { UserService } from '../user'
8+
import type { FilterGroup, FilterGroupInventoryType, FilterGroupsResponse } from './filter-group.types'
9+
10+
@Injectable({ providedIn: 'root' })
11+
export class FilterGroupService {
12+
private _errorService = inject(ErrorService)
13+
private _filterGroups = new BehaviorSubject<FilterGroup[]>([])
14+
private _httpClient = inject(HttpClient)
15+
private _userService = inject(UserService)
16+
17+
filterGroups$ = this._filterGroups.asObservable()
18+
19+
constructor() {
20+
this._userService.currentOrganizationId$
21+
.pipe(
22+
tap((orgId) => {
23+
this.list(orgId, 'Property')
24+
}),
25+
)
26+
.subscribe()
27+
}
28+
29+
list(orgId: number, inventoryType: FilterGroupInventoryType = 'Property') {
30+
const url = `/api/v3/filter_groups/?organization_id=${orgId}&inventory_type=${inventoryType}`
31+
this._httpClient
32+
.get<FilterGroupsResponse>(url)
33+
.pipe(
34+
take(1),
35+
map(({ data }) => {
36+
const filterGroups = data.toSorted((a, b) => naturalSort(a.name, b.name))
37+
this._filterGroups.next(filterGroups)
38+
return filterGroups
39+
}),
40+
catchError((error: HttpErrorResponse) => {
41+
return this._errorService.handleError(error, 'Error fetching filter groups')
42+
}),
43+
)
44+
.subscribe()
45+
}
46+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type FilterGroupInventoryType = 'Property' | 'Tax Lot'
2+
3+
// TODO there are more fields returned that could be added here
4+
export type FilterGroup = {
5+
id: number;
6+
name: string;
7+
organization_id: number;
8+
inventory_type: FilterGroupInventoryType;
9+
}
10+
11+
// TODO this has unhandled pagination
12+
export type FilterGroupsResponse = {
13+
status: string;
14+
data: FilterGroup[];
15+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './filter-group.service'
2+
export * from './filter-group.types'

src/@seed/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './data-quality'
99
export * from './dataset'
1010
export * from './derived-column'
1111
export * from './geocode'
12+
export * from './filter-group'
1213
export * from './groups'
1314
export * from './inventory'
1415
export * from './label'
@@ -18,6 +19,7 @@ export * from './notes'
1819
export * from './organization'
1920
export * from './pairing'
2021
export * from './postoffice'
22+
export * from './program'
2123
export * from './progress'
2224
export * from './salesforce'
2325
export * from './scenario'

src/@seed/api/organization/organization.types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export type OrganizationUserSettings = {
107107
profile?: UserSettingsProfiles;
108108
crossCycles?: UserSettingsCrossCycles;
109109
labels?: UserLabelSettings;
110+
insights?: InsightsUserSettings;
110111
}
111112

112113
type UserSettingsFilters = {
@@ -131,6 +132,22 @@ type UserSettingsCrossCycles = {
131132

132133
type UserLabelSettings = { ids: number[]; operator: LabelOperator }
133134

135+
export type InsightDatasetVisibility = 'compliant' | 'non-compliant' | 'unknown' | 'whisker'
136+
137+
export type PropertyInsightsUserSettings = {
138+
programId?: number | null;
139+
cycleId?: number | null;
140+
metricType?: 0 | 1 | null;
141+
xAxisColumnId?: number | null;
142+
accessLevel?: string | null;
143+
accessLevelInstanceId?: number | null;
144+
datasetVisibility?: InsightDatasetVisibility[];
145+
}
146+
147+
export type InsightsUserSettings = {
148+
propertyInsights?: PropertyInsightsUserSettings;
149+
}
150+
134151
export type OrganizationUsersResponse = {
135152
users: OrganizationUser[];
136153
status: string;

src/@seed/api/program/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './program.service'
2+
export * from './program.types'
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { HttpErrorResponse } from '@angular/common/http'
2+
import { HttpClient } from '@angular/common/http'
3+
import { inject, Injectable } from '@angular/core'
4+
import type { Observable } from 'rxjs'
5+
import { BehaviorSubject, catchError, map, tap } from 'rxjs'
6+
import { ErrorService } from '@seed/services'
7+
import { SnackBarService } from 'app/core/snack-bar/snack-bar.service'
8+
import { UserService } from '../user'
9+
import type { Program, ProgramData, ProgramResponse, ProgramsResponse, ProgramUpsertPayload } from './program.types'
10+
11+
@Injectable({ providedIn: 'root' })
12+
export class ProgramService {
13+
private _httpClient = inject(HttpClient)
14+
private _programs = new BehaviorSubject<Program[]>([])
15+
// private _programs = new ReplaySubject<Program[]>(1)
16+
private _errorService = inject(ErrorService)
17+
private _snackBar = inject(SnackBarService)
18+
private _userService = inject(UserService)
19+
programs$ = this._programs
20+
orgId: number
21+
22+
constructor() {
23+
this._userService.currentOrganizationId$
24+
.pipe(
25+
tap((orgId) => {
26+
this.list(orgId)
27+
}),
28+
)
29+
.subscribe()
30+
}
31+
32+
list(orgId: number) {
33+
const url = `/api/v3/compliance_metrics/?organization_id=${orgId}`
34+
this._httpClient
35+
.get<ProgramsResponse>(url)
36+
.pipe(
37+
map(({ compliance_metrics }) => {
38+
this.programs$.next(compliance_metrics)
39+
return compliance_metrics
40+
}),
41+
catchError((error: HttpErrorResponse) => {
42+
return this._errorService.handleError(error, 'Error fetching Programs')
43+
}),
44+
)
45+
.subscribe()
46+
}
47+
48+
create(orgId: number, data: ProgramUpsertPayload): Observable<ProgramResponse> {
49+
const url = `/api/v3/compliance_metrics/?organization_id=${orgId}`
50+
const payload = this._normalizePayload(data)
51+
return this._httpClient.post<ProgramResponse>(url, payload).pipe(
52+
tap(() => {
53+
this.list(orgId)
54+
this._snackBar.success('Successfully created Program')
55+
}),
56+
catchError((error: HttpErrorResponse) => {
57+
return this._errorService.handleError(error, 'Error creating Program')
58+
}),
59+
)
60+
}
61+
62+
update(orgId: number, programId: number, data: ProgramUpsertPayload): Observable<ProgramResponse> {
63+
const url = `/api/v3/compliance_metrics/${programId}/?organization_id=${orgId}`
64+
const payload = this._normalizePayload(data)
65+
return this._httpClient.put<ProgramResponse>(url, payload).pipe(
66+
tap(() => {
67+
this.list(orgId)
68+
this._snackBar.success('Successfully updated Program')
69+
}),
70+
catchError((error: HttpErrorResponse) => {
71+
return this._errorService.handleError(error, 'Error updating Program')
72+
}),
73+
)
74+
}
75+
76+
delete(orgId: number, programId: number): Observable<ProgramResponse> {
77+
const url = `/api/v3/compliance_metrics/${programId}/?organization_id=${orgId}`
78+
return this._httpClient.delete<ProgramResponse>(url).pipe(
79+
tap(() => {
80+
this.list(orgId)
81+
this._snackBar.success('Successfully deleted Program')
82+
}),
83+
catchError((error: HttpErrorResponse) => {
84+
return this._errorService.handleError(error, 'Error deleting Program')
85+
}),
86+
)
87+
}
88+
89+
evaluate(orgId: number, programId: number, aliId: number = null): Observable<ProgramData> {
90+
let url = `/api/v3/compliance_metrics/${programId}/evaluate/?organization_id=${orgId}`
91+
if (aliId) url += `&access_level_instance_id=${aliId}`
92+
93+
return this._httpClient.get<{ data: ProgramData }>(url).pipe(
94+
map(({ data }) => data),
95+
catchError((error: HttpErrorResponse) => {
96+
return this._errorService.handleError(error, 'Error evaluating Program')
97+
}),
98+
)
99+
}
100+
101+
private _normalizePayload(data: ProgramUpsertPayload): ProgramUpsertPayload {
102+
const payload = { ...data } as ProgramUpsertPayload & Partial<Program>
103+
delete payload.organization_id
104+
delete payload.id
105+
delete payload.energy_bool
106+
delete payload.emission_bool
107+
108+
return {
109+
...payload,
110+
actual_emission_column: payload.actual_emission_column ?? null,
111+
actual_energy_column: payload.actual_energy_column ?? null,
112+
cycles: payload.cycles ?? [],
113+
emission_metric_type: payload.emission_metric_type ?? '',
114+
energy_metric_type: payload.energy_metric_type ?? '',
115+
filter_group: payload.filter_group ?? null,
116+
target_emission_column: payload.target_emission_column ?? null,
117+
target_energy_column: payload.target_energy_column ?? null,
118+
x_axis_columns: payload.x_axis_columns ?? [],
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)