From b3960a786f8b978af1017e5106cf5a77a82fce0e Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Thu, 14 May 2026 17:19:09 -0600 Subject: [PATCH 01/36] initial groups functionality --- .spelling.dic | 3 + src/@seed/api/groups/groups.service.ts | 267 +++++++++++++++++- src/@seed/api/groups/groups.types.ts | 100 ++++++- .../detail/dashboard/dashboard.component.html | 126 +++++++++ .../detail/dashboard/dashboard.component.ts | 98 +++++++ .../detail/group-detail-layout.component.html | 13 + .../detail/group-detail-layout.component.ts | 78 +++++ .../groups/detail/map/map.component.html | 11 + .../groups/detail/map/map.component.ts | 9 + .../delete-meter-dialog.component.html | 18 ++ .../dialogs/delete-meter-dialog.component.ts | 41 +++ .../dialogs/edit-meter-dialog.component.html | 93 ++++++ .../dialogs/edit-meter-dialog.component.ts | 141 +++++++++ .../upload-readings-dialog.component.html | 58 ++++ .../upload-readings-dialog.component.ts | 95 +++++++ .../detail/meters/meters.component.html | 57 ++++ .../groups/detail/meters/meters.component.ts | 217 ++++++++++++++ .../properties/properties.component.html | 27 ++ .../detail/properties/properties.component.ts | 77 +++++ .../groups/detail/systems/dialog-types.ts | 17 ++ .../service-detail.component.html | 61 ++++ .../service-detail.component.ts | 81 ++++++ .../service-dialog.component.html | 42 +++ .../service-dialog.component.ts | 69 +++++ .../system-dialog.component.html | 131 +++++++++ .../system-dialog/system-dialog.component.ts | 105 +++++++ .../detail/systems/systems.component.html | 150 ++++++++++ .../detail/systems/systems.component.ts | 148 ++++++++++ .../inventory-list/groups/groups.component.ts | 23 +- .../groups/modal/form-modal.component.ts | 18 +- src/app/modules/inventory/inventory.routes.ts | 21 ++ 31 files changed, 2368 insertions(+), 27 deletions(-) create mode 100644 src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/map/map.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/map/map.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/meters/meters.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/meters/meters.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/properties/properties.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/properties/properties.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts create mode 100644 src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/systems/systems.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/systems/systems.component.ts diff --git a/.spelling.dic b/.spelling.dic index e5e47e32..518ce85f 100644 --- a/.spelling.dic +++ b/.spelling.dic @@ -78,3 +78,6 @@ unrs unsubscription xmark csrftoken +sankey +evse +EVSE diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index b913b449..b3b82e6d 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -6,7 +6,7 @@ import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory' import { OrganizationService } from '../organization' -import type { InventoryGroup, InventoryGroupResponse, InventoryGroupsResponse } from './groups.types' +import type { GroupDashboard, GroupDashboardResponse, GroupMeter, GroupMetersResponse, GroupMeterUsageResponse, GroupPropertiesResponse, GroupProperty, GroupSankeyEntry, GroupSankeyResponse, GroupService, GroupSystem, InventoryGroup, InventoryGroupResponse, InventoryGroupsResponse, MeterInterval, SystemsByTypeResponse } from './groups.types' @Injectable({ providedIn: 'root' }) export class GroupsService { @@ -21,12 +21,13 @@ export class GroupsService { list(orgId: number) { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` this._httpClient - .get(url) + .get(url) .pipe( take(1), - map(({ data }) => { - this._groups.next(data) - return data + map((data) => { + const groups = Array.isArray(data) ? data : [] + this._groups.next(groups) + return groups }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching groups') @@ -54,13 +55,23 @@ export class GroupsService { .subscribe() } + fetchGroups(orgId: number): Observable { + const url = `/api/v3/inventory_groups/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map((data) => (Array.isArray(data) ? data : [])), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching groups') + }), + ) + } + create(orgId: number, data: InventoryGroup): Observable { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` - return this._httpClient.post(url, data).pipe( - map(({ data }) => { + return this._httpClient.post(url, data).pipe( + map((group) => { this._snackBar.success('Group created successfully') this.list(orgId) - return data + return group }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating group') @@ -68,13 +79,22 @@ export class GroupsService { ) } + get(orgId: number, id: number): Observable { + const url = `/api/v3/inventory_groups/${id}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group') + }), + ) + } + update(orgId: number, id: number, data: InventoryGroup): Observable { const url = `/api/v3/inventory_groups/${id}/?organization_id=${orgId}` - return this._httpClient.put(url, data).pipe( - map(({ data }) => { + return this._httpClient.put(url, data).pipe( + map((group) => { this._snackBar.success('Group updated successfully') this.list(orgId) - return data + return group }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating group') @@ -118,4 +138,229 @@ export class GroupsService { }), ) } + + getById(orgId: number, groupId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group') + }), + ) + } + + getDashboard(orgId: number, groupId: number, cycleId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/dashboard/?organization_id=${orgId}&cycle_id=${cycleId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group dashboard') + }), + ) + } + + getSankeyData(orgId: number, groupId: number, cycleId: number, meterType: string): Observable { + const url = `/api/v3/inventory_groups/${groupId}/dashboard_sankey/?organization_id=${orgId}&cycle_id=${cycleId}&meter_type=${meterType}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching sankey data') + }), + ) + } + + getProperties(orgId: number, groupId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/properties/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group properties') + }), + ) + } + + getMeters(orgId: number, groupId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group meters') + }), + ) + } + + getMeterUsage(orgId: number, groupId: number, interval: MeterInterval): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meter_usage/?organization_id=${orgId}` + return this._httpClient.post(url, { interval }).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching meter usage') + }), + ) + } + + createMeter(orgId: number, groupId: number, meterData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/?organization_id=${orgId}` + return this._httpClient.post(url, meterData).pipe( + tap(() => { + this._snackBar.success('Meter created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating meter') + }), + ) + } + + // Systems CRUD + getSystemsByType(orgId: number, groupId: number): Observable> { + const url = `/api/v3/inventory_groups/${groupId}/systems/systems_by_type/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching systems') + }), + ) + } + + createSystem(orgId: number, groupId: number, systemData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/?organization_id=${orgId}` + return this._httpClient.post(url, systemData).pipe( + tap(() => { + this._snackBar.success('System created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating system') + }), + ) + } + + updateSystem(orgId: number, groupId: number, systemId: number, systemData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/?organization_id=${orgId}` + return this._httpClient.put(url, systemData).pipe( + tap(() => { + this._snackBar.success('System updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating system') + }), + ) + } + + deleteSystem(orgId: number, groupId: number, systemId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this._snackBar.success('System deleted successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting system') + }), + ) + } + + // Services CRUD + getServices(orgId: number, groupId: number, systemId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/?organization_id=${orgId}` + return this._httpClient.get<{ status: string; data: GroupService[] }>(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching services') + }), + ) + } + + createService(orgId: number, groupId: number, systemId: number, serviceData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/?organization_id=${orgId}` + return this._httpClient.post(url, serviceData).pipe( + tap(() => { + this._snackBar.success('Service created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating service') + }), + ) + } + + updateService(orgId: number, groupId: number, systemId: number, serviceId: number, serviceData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` + return this._httpClient.put(url, serviceData).pipe( + tap(() => { + this._snackBar.success('Service updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating service') + }), + ) + } + + deleteService(orgId: number, groupId: number, systemId: number, serviceId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this._snackBar.success('Service deleted successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting service') + }), + ) + } + + getServiceDetail(orgId: number, groupId: number, systemId: number, serviceId: number) { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` + return this._httpClient.get<{ + id: number; + system_name: string; + name: string; + service_meters: { + in: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; + out: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; + }; + properties: { + property_id: number; + property_view_id: number; + property_display_name: string; + meter_id: number; + meter_alias: string; + meter_type: string; + has_meter_data: boolean; + }[]; + }>(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching service detail') + }), + ) + } + + updateMeter(orgId: number, groupId: number, meterId: number, data: { alias?: string; connection_config?: Record }): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/${meterId}/?organization_id=${orgId}` + return this._httpClient.put(url, data).pipe( + tap(() => { + this._snackBar.success('Meter updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating meter') + }), + ) + } + + deleteMeter(orgId: number, groupId: number, meterId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/${meterId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this._snackBar.success('Meter deleted successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting meter') + }), + ) + } + + uploadMeterReadings(orgId: number, importFileId: number, meterId: number): Observable<{ message: string }> { + const url = `/api/v3/import_files/${importFileId}/system_meter_upload/?organization_id=${orgId}` + return this._httpClient.post<{ message: string }>(url, { meter_id: meterId }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error uploading meter readings') + }), + ) + } } diff --git a/src/@seed/api/groups/groups.types.ts b/src/@seed/api/groups/groups.types.ts index 1a369b17..940650e5 100644 --- a/src/@seed/api/groups/groups.types.ts +++ b/src/@seed/api/groups/groups.types.ts @@ -26,18 +26,116 @@ export type GroupSystem = { cooling_capacity: number | null; count: number; des_type: string; + efficiency: number | null; + energy_capacity: number | null; + evse_type: string; group_id: number; heating_capacity: number | null; id: number; mode: string; name: string; + power: number | null; + power_capacity: number | null; services: GroupService[]; type: string; + voltage: number | null; } export type GroupService = { - emission_factor: number; + emission_factor: number | null; id: number; name: string; properties: number[]; } + +export type SystemsByTypeResponse = { + status: string; + data: Record; +} + +export type SystemType = 'DES' | 'EVSE' | 'Battery' | 'Aggregate Meter' + +export type DesType = 'Boiler' | 'Chiller' | 'CHP' + +export type EvseType = 'Level1-120V' | 'Level2-240V' | 'Level3-DC Fast' + +export type GroupDashboardResponse = { + status: string; + data: GroupDashboard; +} + +export type GroupDashboard = { + 'Gross Floor Area': number | null; + 'Site EUI': number | null; + 'Views Count': number; + 'Views Missing Site EUI': number; + 'Views Missing Gross Floor Area': number; + importing_total: Record; + exporting_total: Record; +} + +export type GroupSankeyResponse = { + status: string; + data: GroupSankeyEntry[]; +} + +export type GroupSankeyEntry = { + from: string; + to: string; + flow: number | null; +} + +export type GroupPropertiesResponse = { + status: string; + data: GroupProperty[]; +} + +export type GroupProperty = { + property_id: number; + property_display_name: string; +} + +export type GroupMeterUsageResponse = { + status: string; + data: { + readings: Record[]; + column_defs: { field: string; headerName?: string }[]; + }; +} + +export type GroupMetersResponse = { + status: string; + data: GroupMeter[]; +} + +export type GroupMeterConfig = { + direction: 'imported' | 'exported'; + use: 'outside' | 'using' | 'offering'; + connection: 'outside' | 'service'; + group_id: number | null; + system_id: number | null; + service_id: number | null; +} + +export type GroupMeter = { + id: number; + type: string; + alias: string; + source: string; + source_id: string; + connection_type: string; + property_id: number | null; + property_display_field: string | null; + view_id: number | null; + system_id: number | null; + system_name: string | null; + service_id: number | null; + service_name: string | null; + service_group: number | null; + scenario_id: number | null; + scenario_name: string | null; + is_virtual: boolean; + config: GroupMeterConfig; +} + +export type MeterInterval = 'Exact' | 'Month' | 'Year' diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html new file mode 100644 index 00000000..0ab14bab --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html @@ -0,0 +1,126 @@ + + + +
+ +
+ Cycle + + @for (cycle of cycles; track cycle.cycle_id) { + {{ cycle.name }} + } + +
+ + @if (loading) { +
Loading...
+ } @else if (dashboard) { + +
+
+
Gross Floor Area
+
{{ (dashboard['Gross Floor Area'] ?? 0) | number }}
+
+
+
Site EUI
+
{{ (dashboard['Site EUI'] ?? 0) | number }}
+
+
+
Views Count
+
{{ dashboard['Views Count'] | number }}
+
+
+
Missing Site EUI
+
{{ dashboard['Views Missing Site EUI'] | number }}
+
+
+
Missing Gross Floor Area
+
{{ dashboard['Views Missing Gross Floor Area'] | number }}
+
+
+ + + @if (meterTypes.length) { + + +
+ +
+
Importing Totals
+ @if (dashboard.importing_total | keyvalue; as imports) { + @for (entry of imports; track entry.key) { +
+ {{ entry.key }} + {{ entry.value | number }} +
+ } + } @else { +
No importing data
+ } +
+ + +
+
Exporting Totals
+ @if (dashboard.exporting_total | keyvalue; as exports) { + @for (entry of exports; track entry.key) { +
+ {{ entry.key }} + {{ entry.value | number }} +
+ } + } @else { +
No exporting data
+ } +
+
+ + + + +
+
+ Energy Type + + @for (type of meterTypes; track type) { + {{ type }} + } + +
+ + @if (sankeyData.length) { +
+
Energy Flow
+ @for (entry of sankeyData; track $index) { + @if (entry.flow) { +
+ {{ entry.from }} + + {{ entry.to }} + {{ entry.flow | number }} +
+ } + } +
+ } @else { +
No energy flow data for this type and cycle
+ } +
+ } + } @else { +
No dashboard data available. Add properties to this group to see metrics.
+ } +
diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts new file mode 100644 index 00000000..d22cdab2 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts @@ -0,0 +1,98 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { catchError, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import { of } from 'rxjs' +import type { GroupDashboard, GroupSankeyEntry, OrgCycle } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' + +@Component({ + selector: 'seed-group-dashboard', + templateUrl: './dashboard.component.html', + imports: [CommonModule, MaterialImports, PageComponent], +}) +export class GroupDashboardComponent implements OnDestroy, OnInit { + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private readonly _unsubscribeAll$ = new Subject() + + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + orgId: number + cycleId: number + cycles: OrgCycle[] = [] + dashboard: GroupDashboard | null = null + sankeyData: GroupSankeyEntry[] = [] + meterType = '' + meterTypes: string[] = [] + loading = true + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._organizationService.getById(this.orgId)), + tap((org) => { + this.cycles = org.cycles + this.cycleId = org.cycles[0]?.cycle_id + }), + switchMap(() => this.loadDashboard()), + ) + .subscribe() + } + + loadDashboard() { + this.loading = true + return this._groupsService.getDashboard(this.orgId, this.groupId, this.cycleId).pipe( + tap((data) => { + this.dashboard = data + this.meterTypes = [ + ...Object.keys(data?.importing_total ?? {}), + ...Object.keys(data?.exporting_total ?? {}), + ].filter((v, i, a) => a.indexOf(v) === i) + if (this.meterTypes.length && !this.meterType) { + this.meterType = this.meterTypes[0] + } + this.loading = false + }), + catchError(() => { + this.dashboard = null + this.loading = false + return of(null) + }), + ) + } + + changeCycle(cycleId: number) { + this.cycleId = cycleId + this.loadDashboard().pipe( + switchMap(() => this.loadSankey()), + ).subscribe() + } + + loadSankey() { + if (!this.meterType) return this._groupsService.getSankeyData(this.orgId, this.groupId, this.cycleId, '') + return this._groupsService.getSankeyData(this.orgId, this.groupId, this.cycleId, this.meterType).pipe( + tap((data) => { + this.sankeyData = data + }), + ) + } + + changeMeterType(meterType: string) { + this.meterType = meterType + this.loadSankey().subscribe() + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html new file mode 100644 index 00000000..67c52c9c --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html @@ -0,0 +1,13 @@ +
+ + + + + + +
+ +
+
+
+
diff --git a/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts new file mode 100644 index 00000000..8134dc6b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts @@ -0,0 +1,78 @@ +import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import type { MatDrawer } from '@angular/material/sidenav' +import { ActivatedRoute, RouterOutlet } from '@angular/router' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { InventoryGroup } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import type { NavigationItem } from '@seed/components' +import { DrawerService, VerticalNavigationComponent } from '@seed/components' +import { ScrollResetDirective } from '@seed/directives' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory/inventory.types' + +@Component({ + selector: 'seed-group-detail-layout', + templateUrl: './group-detail-layout.component.html', + imports: [MaterialImports, RouterOutlet, ScrollResetDirective, VerticalNavigationComponent], +}) +export class GroupDetailLayoutComponent implements AfterViewInit, OnDestroy, OnInit { + @ViewChild('drawer') drawer!: MatDrawer + private _drawerService = inject(DrawerService) + private _activatedRoute = inject(ActivatedRoute) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private readonly _unsubscribeAll$ = new Subject() + + groupId = parseInt(this._activatedRoute.snapshot.paramMap.get('groupId')) + type = this._activatedRoute.snapshot.paramMap.get('type') as InventoryType + orgId: number + group: InventoryGroup + + navigationMenu: NavigationItem[] = [] + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._groupsService.getById(this.orgId, this.groupId)), + tap((group) => { + this.group = group + this.buildNavigation() + }), + ) + .subscribe() + } + + buildNavigation() { + const base = `/${this.type}/groups/${this.groupId}` + this.navigationMenu = [ + { + id: 'group-detail', + title: this.group?.name ?? 'Group', + type: 'group', + children: [ + { id: 'dashboard', link: `${base}/dashboard`, title: 'Dashboard', type: 'basic', exactMatch: true }, + { id: 'properties', link: `${base}/properties`, title: 'Properties', type: 'basic' }, + { id: 'systems', link: `${base}/systems`, title: 'Systems & Services', type: 'basic' }, + { id: 'meters', link: `${base}/meters`, title: 'Meters', type: 'basic' }, + { id: 'map', link: `${base}/map`, title: 'Map', type: 'basic' }, + ], + }, + ] + } + + ngAfterViewInit() { + this._drawerService.setDrawer(this.drawer) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/map/map.component.html b/src/app/modules/inventory-list/groups/detail/map/map.component.html new file mode 100644 index 00000000..60048d7b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/map/map.component.html @@ -0,0 +1,11 @@ + + + +
+
Map view coming soon.
+
diff --git a/src/app/modules/inventory-list/groups/detail/map/map.component.ts b/src/app/modules/inventory-list/groups/detail/map/map.component.ts new file mode 100644 index 00000000..fcd145e9 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/map/map.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core' +import { PageComponent } from '@seed/components' + +@Component({ + selector: 'seed-group-map', + templateUrl: './map.component.html', + imports: [PageComponent], +}) +export class GroupMapComponent {} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html new file mode 100644 index 00000000..f35fa81d --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html @@ -0,0 +1,18 @@ +
+
+
Delete Meter
+

Are you sure you want to delete the meter {{ meterName }}?

+

This action cannot be undone. All associated meter readings will also be deleted.

+
+ +
+ + +
+
diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts new file mode 100644 index 00000000..4e2e728e --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts @@ -0,0 +1,41 @@ +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export type DeleteMeterDialogData = { + orgId: number; + groupId: number; + meter: { + id: number; + alias: string; + type: string; + }; +} + +@Component({ + selector: 'seed-delete-meter-dialog', + templateUrl: './delete-meter-dialog.component.html', + imports: [MaterialImports], +}) +export class DeleteMeterDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as DeleteMeterDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + meterName = this._data.meter.alias || `${this._data.meter.type} (ID: ${this._data.meter.id})` + submitted = false + + confirm() { + if (this.submitted) return + this.submitted = true + + this._groupsService.deleteMeter(this._data.orgId, this._data.groupId, this._data.meter.id) + .subscribe({ + next: () => this._dialogRef.close(true), + error: () => { + this.submitted = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html new file mode 100644 index 00000000..1fc9a7db --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html @@ -0,0 +1,93 @@ +
+
+
Configure Meter Details
+ +
+ + + Alias + + + + + + + + Flow Direction + + Imported + Exported + + + + + + Connection + + Connected to Outside + Connected to a Service + + + + @if (!loading && connection === 'service') { + + + + Meter Usage + info + + + @for (option of useOptions; track option.value) { + {{ option.display }} + } + + + + + @if (use) { + + System + + @for (system of systemOptions; track system.id) { + {{ system.name }} + } + + + } + + + @if (selectedSystemId !== null) { + + Service + + @for (service of serviceOptions; track service.id) { + {{ service.name }} + } + + + } + } + + @if (loading && connection === 'service') { +
+ +
+ } +
+ + @if (error) { +
{{ error }}
+ } +
+ +
+ + +
+
diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts new file mode 100644 index 00000000..0e49cbb4 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts @@ -0,0 +1,141 @@ +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import type { GroupMeterConfig, GroupService, GroupSystem } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export type EditMeterDialogData = { + orgId: number; + groupId: number; + meter: { + id: number; + alias: string; + connection_type: string; + property_id: number | null; + system_id: number | null; + service_id: number | null; + config: GroupMeterConfig; + }; +} + +@Component({ + selector: 'seed-edit-meter-dialog', + templateUrl: './edit-meter-dialog.component.html', + imports: [FormsModule, MaterialImports], +}) +export class EditMeterDialogComponent implements OnInit { + private _data = inject(MAT_DIALOG_DATA) as EditMeterDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + alias = this._data.meter.alias ?? '' + direction: 'imported' | 'exported' = this._data.meter.config?.direction ?? 'imported' + connection: 'outside' | 'service' = this._data.meter.config?.connection ?? 'outside' + use: 'using' | 'offering' | null = this._data.meter.config?.use !== 'outside' ? (this._data.meter.config?.use ?? null) : null + selectedSystemId: number | null = this._data.meter.config?.system_id ?? null + selectedServiceId: number | null = this._data.meter.config?.service_id ?? null + + systemOptions: GroupSystem[] = [] + serviceOptions: GroupService[] = [] + useOptions: { value: string; display: string }[] = [{ value: 'using', display: 'Using a Service' }] + + loading = true + submitted = false + error = '' + + // Property meters can't change "use" — it's always "using" + get isPropertyMeter(): boolean { + return !!this._data.meter.property_id + } + + // System meters can also "offer" a service + get isSystemMeter(): boolean { + return !!this._data.meter.system_id + } + + ngOnInit() { + if (this.isSystemMeter) { + this.useOptions.push({ value: 'offering', display: 'Offering a Service (Total)' }) + } + + // Fetch the current group to get its systems/services + this._groupsService.get(this._data.orgId, this._data.groupId).subscribe((group) => { + this.systemOptions = group.systems ?? [] + // Pre-populate service options from config + if (this.selectedSystemId) { + this.onSystemSelected(false) + } + this.loading = false + }) + } + + onConnectionChanged() { + this.use = null + this.selectedSystemId = null + this.selectedServiceId = null + this.serviceOptions = [] + this.error = '' + + if (this.connection === 'service' && this.isPropertyMeter) { + this.use = 'using' + } + } + + onUseSelected(reset = true) { + if (reset) { + this.selectedSystemId = null + this.selectedServiceId = null + this.serviceOptions = [] + } + + // If "offering", system is already known + if (this.isSystemMeter && this.use === 'offering') { + this.selectedSystemId = this._data.meter.system_id + this.onSystemSelected(reset) + } + } + + onSystemSelected(reset = true) { + if (reset) { + this.selectedServiceId = null + } + const system = this.systemOptions.find((s) => s.id === this.selectedSystemId) + this.serviceOptions = system?.services ?? [] + } + + get formValid(): boolean { + if (!this.direction) return false + if (this.connection === 'outside') return true + return !!this.use && !!this.selectedSystemId && !!this.selectedServiceId + } + + save() { + if (this.submitted || !this.formValid) return + this.submitted = true + this.error = '' + + const config: Record = { + direction: this.direction, + use: this.connection === 'outside' ? 'outside' : this.use, + } + + if (this.connection === 'service' && this.selectedServiceId) { + config.service_id = this.selectedServiceId + } + + const payload: { alias?: string; connection_config: Record } = { connection_config: config } + if (this.alias !== this._data.meter.alias) { + payload.alias = this.alias + } + + this._groupsService.updateMeter(this._data.orgId, this._data.groupId, this._data.meter.id, payload) + .subscribe({ + next: () => this._dialogRef.close(true), + error: () => { + this.submitted = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html new file mode 100644 index 00000000..6123b457 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html @@ -0,0 +1,58 @@ +
+
+
Add Meter Readings to {{ meterName }}
+ + @if (state === 'upload') { +
+

+ Upload a CSV or XLSX file with columns: Start Date, End Date, Usage Units, Reading. +

+ +
+ + +
+ + @if (selectedFile) { +
+ + {{ selectedFile.name }} +
+ } + + @if (invalidExtension) { +
+ Invalid file type. Please upload a CSV or XLSX file. +
+ } +
+ } + + @if (state === 'processing') { +
+ +

Processing meter readings...

+
+ } + + @if (state === 'confirmation') { +
+

{{ confirmationMessage }}

+
+ } +
+ +
+ @if (state === 'upload') { + + + } @else if (state === 'confirmation') { + + } +
+
diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts new file mode 100644 index 00000000..b7d6f90c --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts @@ -0,0 +1,95 @@ +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { catchError, of, switchMap } from 'rxjs' +import { GroupsService, OrganizationService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + +export type UploadReadingsDialogData = { + orgId: number; + groupId: number; + meter: { + id: number; + alias: string; + type: string; + }; +} + +@Component({ + selector: 'seed-upload-readings-dialog', + templateUrl: './upload-readings-dialog.component.html', + imports: [MaterialImports], +}) +export class UploadReadingsDialogComponent implements OnInit { + private _data = inject(MAT_DIALOG_DATA) as UploadReadingsDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _httpClient = inject(HttpClient) + private _snackBar = inject(SnackBarService) + + meterName = this._data.meter.alias || `${this._data.meter.type} (ID: ${this._data.meter.id})` + state: 'upload' | 'processing' | 'confirmation' = 'upload' + confirmationMessage = '' + selectedFile: File | null = null + invalidExtension = false + orgId: number + + ngOnInit() { + this.orgId = this._data.orgId + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext !== 'csv' && ext !== 'xlsx') { + this.invalidExtension = true + this.selectedFile = null + return + } + + this.invalidExtension = false + this.selectedFile = file + } + + upload() { + if (!this.selectedFile) return + this.state = 'processing' + + const datasetName = `Meter Readings Upload - ${new Date().toISOString()}` + + // Step 1: Create dataset + this._httpClient.post<{ id: number }>(`/api/v3/datasets/?organization_id=${this.orgId}`, { name: datasetName }) + .pipe( + // Step 2: Upload file + switchMap((dataset) => { + const formData = new FormData() + formData.append('file', this.selectedFile) + formData.append('import_record', dataset.id.toString()) + formData.append('source_type', 'Meter Data') + return this._httpClient.post<{ import_file_id: number }>(`/api/v3/upload/?organization_id=${this.orgId}`, formData) + }), + // Step 3: Process meter readings + switchMap((uploadResult) => { + return this._groupsService.uploadMeterReadings(this.orgId, uploadResult.import_file_id, this._data.meter.id) + }), + catchError((error) => { + const message = error?.error?.message || error?.message || 'Upload failed' + return of({ message: `Failure: ${message}` }) + }), + ) + .subscribe((result) => { + this.state = 'confirmation' + this.confirmationMessage = result.message + }) + } + + dismiss() { + this._dialogRef.close(true) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.html b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html new file mode 100644 index 00000000..9ce340e8 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html @@ -0,0 +1,57 @@ + + + +
+ +
+
Meters
+
Note: Meters are labeled with the following format: "Type - Source - Source ID".
+
+ @if (loadingMeters) { +
Loading meters...
+ } @else if (meters.length === 0) { +
No meters in this group
+ } @else { + + } + + + + +
+
Readings
+ + @for (i of intervals; track i) { + {{ i }} + } + +
+ + @if (loadingReadings) { +
Loading readings...
+ } @else if (readings.length) { + + } +
diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts new file mode 100644 index 00000000..fcb09a03 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts @@ -0,0 +1,217 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { MatDialog } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellClickedEvent, ColDef } from 'ag-grid-community' +import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupMeter, MeterInterval } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { EditMeterDialogComponent } from './dialogs/edit-meter-dialog.component' +import type { EditMeterDialogData } from './dialogs/edit-meter-dialog.component' +import { DeleteMeterDialogComponent } from './dialogs/delete-meter-dialog.component' +import type { DeleteMeterDialogData } from './dialogs/delete-meter-dialog.component' +import { UploadReadingsDialogComponent } from './dialogs/upload-readings-dialog.component' +import type { UploadReadingsDialogData } from './dialogs/upload-readings-dialog.component' + +ModuleRegistry.registerModules([AllCommunityModule]) + +@Component({ + selector: 'seed-group-meters', + templateUrl: './meters.component.html', + imports: [AgGridAngular, MaterialImports, PageComponent], +}) +export class GroupMetersComponent implements OnDestroy, OnInit { + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + + private _dialog = inject(MatDialog) + + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + inventoryType = this._route.parent?.parent?.parent?.snapshot?.url?.[0]?.path ?? 'properties' + orgId: number + meters: GroupMeter[] = [] + readings: Record[] = [] + readingColumnDefs: ColDef[] = [] + selectedMeter: GroupMeter | null = null + interval: MeterInterval = 'Month' + intervals: MeterInterval[] = ['Exact', 'Month', 'Year'] + loadingMeters = true + loadingReadings = false + + meterColumnDefs: ColDef[] = [ + { headerName: 'ID', field: 'id', width: 80 }, + { headerName: 'Type', field: 'type', flex: 1 }, + { headerName: 'Alias', field: 'alias', flex: 1 }, + { headerName: 'Source', field: 'source', width: 130 }, + { headerName: 'Source ID', field: 'source_id', width: 120 }, + { headerName: 'Scenario ID', field: 'scenario_id', width: 110 }, + { headerName: 'Connection Type', field: 'connection_type', width: 150 }, + { + headerName: 'Property', + field: 'property_display_field', + flex: 1, + cellRenderer: (params: { value: string; data: GroupMeter }) => { + if (!params.value || !params.data?.view_id) return params.value ?? '' + return `${params.value}` + }, + }, + { headerName: 'System', field: 'system_name', width: 130 }, + { + headerName: 'Connection', + field: 'service_name', + width: 140, + cellRenderer: (params: { value: string; data: GroupMeter }) => { + if (!params.value || !params.data?.service_group) return params.value ?? '' + return `${params.value}` + }, + }, + { headerName: 'Virtual', field: 'is_virtual', width: 90 }, + { headerName: 'Scenario', field: 'scenario_name', width: 130 }, + { + headerName: 'Actions', + field: 'actions', + width: 150, + sortable: false, + filter: false, + cellRenderer: () => { + return `
+ + + +
` + }, + }, + ] + + defaultColDef: ColDef = { + sortable: true, + filter: true, + resizable: true, + } + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._groupsService.getMeters(this.orgId, this.groupId)), + tap((data) => { + this.meters = data + this.loadingMeters = false + this.loadReadings() + }), + ) + .subscribe() + } + + loadReadings() { + this.loadingReadings = true + this._groupsService.getMeterUsage(this.orgId, this.groupId, this.interval) + .subscribe({ + next: (data) => { + this.readings = data.readings + this.readingColumnDefs = data.column_defs.map((col) => ({ + headerName: col.headerName ?? col.field, + field: col.field, + flex: 1, + sortable: true, + filter: true, + })) + this.loadingReadings = false + }, + error: () => { + this.readings = [] + this.readingColumnDefs = [] + this.loadingReadings = false + }, + }) + } + + changeInterval(interval: MeterInterval) { + this.interval = interval + this.loadReadings() + } + + onMeterCellClicked(event: CellClickedEvent) { + const target = event.event?.target as HTMLElement + const action = target?.closest('[data-action]')?.getAttribute('data-action') + if (!action) return + + const meter = event.data as GroupMeter + switch (action) { + case 'edit': + this.editMeter(meter) + break + case 'delete': + this.deleteMeter(meter) + break + case 'add-readings': + this.addReadings(meter) + break + case 'navigate-property': + if (meter.view_id) { + this._router.navigate(['/', this.inventoryType, meter.view_id, 'meters']) + } + break + case 'navigate-connection': + if (meter.service_group) { + this._router.navigate(['/', this.inventoryType, 'groups', meter.service_group, 'systems']) + } + break + } + } + + editMeter(meter: GroupMeter) { + const data: EditMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } + this._dialog.open(EditMeterDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.refreshMeters())) + .subscribe() + } + + deleteMeter(meter: GroupMeter) { + const data: DeleteMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } + this._dialog.open(DeleteMeterDialogComponent, { data, width: '400px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.refreshMeters())) + .subscribe() + } + + addReadings(meter: GroupMeter) { + const data: UploadReadingsDialogData = { orgId: this.orgId, groupId: this.groupId, meter } + this._dialog.open(UploadReadingsDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.refreshMeters())) + .subscribe() + } + + private refreshMeters() { + return this._groupsService.getMeters(this.orgId, this.groupId).pipe( + tap((data) => { + this.meters = data + }), + ) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.html b/src/app/modules/inventory-list/groups/detail/properties/properties.component.html new file mode 100644 index 00000000..6d9c22bc --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.html @@ -0,0 +1,27 @@ + + + +
+ @if (loading) { +
Loading...
+ } @else if (properties.length === 0) { +
+ No properties in this group. Properties can be added from the + Inventory → Properties page. +
+ } @else { + + } +
diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts new file mode 100644 index 00000000..6ff78b0b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts @@ -0,0 +1,77 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { ActivatedRoute, Router, RouterLink } from '@angular/router' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupProperty } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' + +ModuleRegistry.registerModules([AllCommunityModule]) + +@Component({ + selector: 'seed-group-properties', + templateUrl: './properties.component.html', + imports: [AgGridAngular, PageComponent, RouterLink], +}) +export class GroupPropertiesComponent implements OnDestroy, OnInit { + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + orgId: number + properties: GroupProperty[] = [] + loading = true + + columnDefs: ColDef[] = [ + { + headerName: 'Property Name', + field: 'property_display_name', + flex: 1, + valueGetter: ({ data }) => data?.property_display_name || `Property ${data?.property_id}`, + }, + { + headerName: 'Property ID', + field: 'property_id', + width: 150, + }, + ] + + defaultColDef: ColDef = { + sortable: true, + filter: true, + resizable: true, + } + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._groupsService.getProperties(this.orgId, this.groupId)), + tap((data) => { + this.properties = data + this.loading = false + }), + ) + .subscribe() + } + + onRowClicked(event: { data: GroupProperty }) { + void this._router.navigate(['/properties', event.data.property_id]) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts b/src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts new file mode 100644 index 00000000..9e195764 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts @@ -0,0 +1,17 @@ +import type { GroupSystem } from '@seed/api' + +export type SystemDialogData = { + action: 'create' | 'edit' | 'delete'; + orgId: number; + groupId: number; + system?: GroupSystem; +} + +export type ServiceDialogData = { + action: 'create' | 'edit' | 'delete'; + orgId: number; + groupId: number; + systemId: number; + systemName: string; + service?: { id: number; name: string; emission_factor: number | null }; +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html new file mode 100644 index 00000000..d8a0ea5d --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html @@ -0,0 +1,61 @@ + + + +
+ +
+ +
+ + @if (loading) { +
Loading...
+ } @else if (!service) { +
Service not found
+ } @else { + +
+
Connected Properties
+ + @if (service.properties.length) { + + + + + + + + + + + @for (prop of service.properties; track prop.property_id) { + + + + + + + } + +
PropertyConnected ViaConnection TypeHas Meter Data
+ + {{ prop.property_display_name || 'Property ' + prop.property_id }} + + + + {{ prop.meter_alias || 'Meter ' + prop.meter_id }} + + {{ prop.meter_type }}{{ prop.has_meter_data ? 'Yes' : 'No' }}
+ } @else { +
No properties connected to this service
+ } +
+ } +
diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts new file mode 100644 index 00000000..19389b60 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts @@ -0,0 +1,81 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { ActivatedRoute, Router, RouterLink } from '@angular/router' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' + +@Component({ + selector: 'seed-service-detail', + templateUrl: './service-detail.component.html', + imports: [MaterialImports, PageComponent, RouterLink], +}) +export class ServiceDetailComponent implements OnDestroy, OnInit { + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + + groupId: number + systemId: number + serviceId: number + orgId: number + inventoryType: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service: any = null + loading = true + + ngOnInit() { + this.systemId = parseInt(this._route.snapshot.paramMap.get('systemId')) + this.serviceId = parseInt(this._route.snapshot.paramMap.get('serviceId')) + + // Walk up to find groupId from parent routes + let route = this._route.parent + while (route) { + const gid = route.snapshot.paramMap.get('groupId') + if (gid) { + this.groupId = parseInt(gid) + break + } + route = route.parent + } + + // Get inventory type from URL + const urlParts = this._router.url.split('/') + this.inventoryType = urlParts.find((p) => p === 'properties' || p === 'taxlots') ?? 'properties' + + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this.loadService()), + ) + .subscribe() + } + + loadService() { + this.loading = true + return this._groupsService.getServiceDetail(this.orgId, this.groupId, this.systemId, this.serviceId).pipe( + tap((data) => { + this.service = data + this.loading = false + }), + ) + } + + goBackToSystems() { + // Build absolute path: /:inventoryType/groups/:groupId/systems + void this._router.navigate(['/', this.inventoryType, 'groups', this.groupId, 'systems']) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html new file mode 100644 index 00000000..2f8d8155 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html @@ -0,0 +1,42 @@ +
+
+
{{ title }}
+ + @if (action === 'delete') { +

Are you sure you want to delete the service {{ serviceName }}?

+ } @else { +
+ + Service Name + + + + + Emission Factor + + +
+ } +
+ +
+ + @if (action === 'delete') { + + } @else { + + } +
+
diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts new file mode 100644 index 00000000..a2a1b075 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts @@ -0,0 +1,69 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { finalize } from 'rxjs' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import type { ServiceDialogData } from '../dialog-types' + +@Component({ + selector: 'seed-service-dialog', + templateUrl: './service-dialog.component.html', + imports: [FormsModule, MaterialImports], +}) +export class ServiceDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as ServiceDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + action = this._data.action + systemName = this._data.systemName + serviceName = this._data.service?.name ?? '' + emissionFactor: number | null = this._data.service?.emission_factor ?? null + submitted = false + + get title(): string { + if (this.action === 'create') return `Create Service for ${this.systemName}` + if (this.action === 'edit') return `Edit Service for ${this.systemName}` + return `Delete Service for ${this.systemName}` + } + + get isValid(): boolean { + return this.serviceName.trim().length > 0 + } + + save() { + if (this.submitted) return + this.submitted = true + + const payload = { + name: this.serviceName.trim(), + emission_factor: this.emissionFactor, + system_id: this._data.systemId, + } + + const serviceId = this._data.service?.id + const obs = this.action === 'create' + ? this._groupsService.createService(this._data.orgId, this._data.groupId, this._data.systemId, payload) + : this._groupsService.updateService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId, payload) + + obs.pipe( + finalize(() => { + this._dialogRef.close(true) + }), + ).subscribe() + } + + deleteService() { + if (this.submitted) return + this.submitted = true + const serviceId = this._data.service?.id + this._groupsService.deleteService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId) + .pipe( + finalize(() => { + this._dialogRef.close(true) + }), + ) + .subscribe() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html new file mode 100644 index 00000000..35757f65 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html @@ -0,0 +1,131 @@ +
+
+
{{ title }}
+ + @if (action === 'delete') { +

Are you sure you want to delete the system {{ systemName }}?

+ } @else { +
+ + + System Name + + + + + @if (!isEdit) { + + System Type + + @for (t of systemTypes; track t) { + {{ t }} + } + + + } + + + @if (systemType === 'DES') { + + DES Type + + @for (t of desTypes; track t) { + {{ t }} + } + + + + @if (desType !== 'Chiller') { + + Heating Capacity (MMBtu) + + + } + + @if (desType === 'Chiller' || desType === 'CHP') { + + Cooling Capacity (Ton) + + + } + + + Count + + + } + + + @if (systemType === 'EVSE') { + + EVSE Type + + @for (t of evseTypes; track t) { + {{ t }} + } + + + + + Power (kW) + + + + + Voltage (V) + + + + + Count + + + } + + + @if (systemType === 'Battery') { + + Efficiency (%) + + + + + Power Capacity (kW) + + + + + Energy Capacity (kWh) + + + + + Voltage (V) + + + } +
+ } +
+ +
+ + @if (action === 'delete') { + + } @else { + + } +
+
diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts new file mode 100644 index 00000000..1ea6e1df --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts @@ -0,0 +1,105 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { finalize } from 'rxjs' +import type { DesType, EvseType, GroupSystem, SystemType } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import type { SystemDialogData } from '../dialog-types' + +@Component({ + selector: 'seed-system-dialog', + templateUrl: './system-dialog.component.html', + imports: [FormsModule, MaterialImports], +}) +export class SystemDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as SystemDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + action = this._data.action + systemName = this._data.system?.name ?? '' + systemType: SystemType = (this._data.system?.type as SystemType) ?? 'DES' + desType: DesType = (this._data.system?.des_type as DesType) ?? 'Boiler' + evseType: EvseType = (this._data.system?.evse_type as EvseType) ?? 'Level1-120V' + coolingCapacity: number | null = this._data.system?.cooling_capacity ?? null + heatingCapacity: number | null = this._data.system?.heating_capacity ?? null + count = this._data.system?.count ?? 1 + power: number | null = this._data.system?.power ?? null + voltage: number | null = this._data.system?.voltage ?? null + efficiency: number | null = this._data.system?.efficiency ?? null + powerCapacity: number | null = this._data.system?.power_capacity ?? null + energyCapacity: number | null = this._data.system?.energy_capacity ?? null + submitted = false + isEdit = this._data.action === 'edit' + + systemTypes: SystemType[] = ['DES', 'EVSE', 'Battery', 'Aggregate Meter'] + desTypes: DesType[] = ['Boiler', 'Chiller', 'CHP'] + evseTypes: EvseType[] = ['Level1-120V', 'Level2-240V', 'Level3-DC Fast'] + + get title(): string { + if (this.action === 'create') return 'Create System' + if (this.action === 'edit') return 'Edit System' + return 'Delete System' + } + + get isValid(): boolean { + if (!this.systemName.trim()) return false + if (this.systemType === 'EVSE') return this.power != null && this.voltage != null + if (this.systemType === 'Battery') { + return this.efficiency != null && this.powerCapacity != null && this.energyCapacity != null && this.voltage != null + } + return true + } + + save() { + if (this.submitted) return + this.submitted = true + + const payload: Partial = { name: this.systemName.trim() } + + // Backend needs `type` for both create and update to resolve the model class + payload.type = this.systemType + + if (this.systemType === 'DES') { + payload.des_type = this.desType + payload.count = this.count + payload.cooling_capacity = this.desType === 'Chiller' ? this.coolingCapacity : null + payload.heating_capacity = this.desType !== 'Chiller' ? this.heatingCapacity : null + } else if (this.systemType === 'EVSE') { + payload.evse_type = this.evseType + payload.power = this.power + payload.voltage = this.voltage + payload.count = this.count + } else if (this.systemType === 'Battery') { + payload.efficiency = this.efficiency + payload.power_capacity = this.powerCapacity + payload.energy_capacity = this.energyCapacity + payload.voltage = this.voltage + } + + const systemId = this._data.system?.id + const obs = this.action === 'create' + ? this._groupsService.createSystem(this._data.orgId, this._data.groupId, payload) + : this._groupsService.updateSystem(this._data.orgId, this._data.groupId, systemId, payload) + + obs.pipe( + finalize(() => { + this._dialogRef.close(true) + }), + ).subscribe() + } + + deleteSystem() { + if (this.submitted) return + this.submitted = true + const systemId = this._data.system?.id + this._groupsService.deleteSystem(this._data.orgId, this._data.groupId, systemId) + .pipe( + finalize(() => { + this._dialogRef.close(true) + }), + ) + .subscribe() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.html b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html new file mode 100644 index 00000000..ec608c92 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html @@ -0,0 +1,150 @@ + + + +
+ +
+ +
+ + @if (loading) { +
Loading...
+ } @else if (systemTypeKeys.length === 0) { +
No systems defined for this group
+ } @else { + @for (typeKey of systemTypeKeys; track typeKey) { +
+
{{ typeKey }}
+ + + + + + + + @if (getBaseType(typeKey) === 'DES') { + + + + + } + @if (getBaseType(typeKey) === 'EVSE') { + + + + + } + @if (getBaseType(typeKey) === 'Battery') { + + + + + } + @if (getBaseType(typeKey) === 'Aggregate Meter') { + + } + + + + + @for (system of systemsByType[typeKey]; track system.id) { + + + + + @if (getBaseType(typeKey) === 'DES') { + + + + + } + @if (getBaseType(typeKey) === 'EVSE') { + + + + + } + @if (getBaseType(typeKey) === 'Battery') { + + + + + } + @if (getBaseType(typeKey) === 'Aggregate Meter') { + + } + + + + @if (expandedSystems.has(system.id) && system.services?.length) { + + + + } + } + +
NameDES TypeHeating Capacity (MMBtu)Cooling Capacity (Ton)CountEVSE TypePower (kW)Voltage (V)CountEfficiency (%)Power Capacity (kW)Energy Capacity (kWh)Voltage (V)ModeActions
+ @if (system.services?.length) { + + } + {{ system.name }}{{ system.des_type || '—' }}{{ system.heating_capacity ?? '—' }}{{ system.cooling_capacity ?? '—' }}{{ system.count }}{{ system.evse_type || '—' }}{{ system.power ?? '—' }}{{ system.voltage ?? '—' }}{{ system.count }}{{ system.efficiency ?? '—' }}{{ system.power_capacity ?? '—' }}{{ system.energy_capacity ?? '—' }}{{ system.voltage ?? '—' }}{{ system.mode || '—' }} +
+ + + {{ system.services?.length ?? 0 }} {{ (system.services?.length ?? 0) === 1 ? 'service' : 'services' }} + + + + +
+
+ + + + + + + + + + @for (service of system.services; track service.id) { + + + + + + } + +
NameEmission FactorActions
{{ service.name }}{{ service.emission_factor ?? '—' }} + + + +
+
+
+ } + } +
diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts b/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts new file mode 100644 index 00000000..af01cda2 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts @@ -0,0 +1,148 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { MatTooltipModule } from '@angular/material/tooltip' +import { ActivatedRoute, Router } from '@angular/router' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupService, GroupSystem } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import type { ServiceDialogData, SystemDialogData } from './dialog-types' +import { ServiceDialogComponent } from './service-dialog/service-dialog.component' +import { SystemDialogComponent } from './system-dialog/system-dialog.component' + +@Component({ + selector: 'seed-group-systems', + templateUrl: './systems.component.html', + imports: [MaterialImports, MatTooltipModule, PageComponent], +}) +export class GroupSystemsComponent implements OnDestroy, OnInit { + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _dialog = inject(MatDialog) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + orgId: number + systemsByType: Record = {} + systemTypeKeys: string[] = [] + expandedSystems = new Set() + loading = true + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + loadSystems() { + this.loading = true + return this._groupsService.getSystemsByType(this.orgId, this.groupId).pipe( + tap((data) => { + this.systemsByType = data + this.systemTypeKeys = Object.keys(data) + this.loading = false + }), + ) + } + + createSystem() { + const data: SystemDialogData = { action: 'create', orgId: this.orgId, groupId: this.groupId } + this._dialog.open(SystemDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .subscribe() + } + + editSystem(system: GroupSystem) { + const data: SystemDialogData = { action: 'edit', orgId: this.orgId, groupId: this.groupId, system } + this._dialog.open(SystemDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .subscribe() + } + + deleteSystem(system: GroupSystem) { + const data: SystemDialogData = { action: 'delete', orgId: this.orgId, groupId: this.groupId, system } + this._dialog.open(SystemDialogComponent, { data, width: '400px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .subscribe() + } + + createService(system: GroupSystem) { + const data: ServiceDialogData = { + action: 'create', orgId: this.orgId, groupId: this.groupId, + systemId: system.id, systemName: system.name, + } + this._dialog.open(ServiceDialogComponent, { data, width: '450px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .subscribe() + } + + editService(system: GroupSystem, service: GroupService) { + const data: ServiceDialogData = { + action: 'edit', orgId: this.orgId, groupId: this.groupId, + systemId: system.id, systemName: system.name, service, + } + this._dialog.open(ServiceDialogComponent, { data, width: '450px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .subscribe() + } + + deleteService(system: GroupSystem, service: GroupService) { + const data: ServiceDialogData = { + action: 'delete', orgId: this.orgId, groupId: this.groupId, + systemId: system.id, systemName: system.name, service, + } + this._dialog.open(ServiceDialogComponent, { data, width: '400px' }) + .afterClosed() + .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .subscribe() + } + + toggleServices(systemId: number) { + if (this.expandedSystems.has(systemId)) { + this.expandedSystems.delete(systemId) + } else { + this.expandedSystems.add(systemId) + } + } + + getColspan(typeKey: string): number { + const base = this.getBaseType(typeKey) + // +1 for the expand/collapse column + const colMap: Record = { DES: 7, EVSE: 7, Battery: 7, 'Aggregate Meter': 4 } + return colMap[base] ?? 4 + } + + getBaseType(typeKey: string): string { + if (typeKey.startsWith('DES')) return 'DES' + if (typeKey.startsWith('EVSE')) return 'EVSE' + if (typeKey.startsWith('Battery')) return 'Battery' + if (typeKey.startsWith('Aggregate Meter')) return 'Aggregate Meter' + return typeKey + } + + viewServiceDetail(system: GroupSystem, service: GroupService) { + void this._router.navigate(['services', system.id, service.id], { relativeTo: this._route }) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/groups.component.ts b/src/app/modules/inventory-list/groups/groups.component.ts index 88375a9a..036375b1 100644 --- a/src/app/modules/inventory-list/groups/groups.component.ts +++ b/src/app/modules/inventory-list/groups/groups.component.ts @@ -3,7 +3,7 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import type { MatDialogRef } from '@angular/material/dialog' import { MatDialog } from '@angular/material/dialog' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { Observable } from 'rxjs' @@ -26,6 +26,7 @@ export class GroupsComponent implements OnDestroy, OnInit { private _groupsService = inject(GroupsService) private _organizationService = inject(OrganizationService) private _route = inject(ActivatedRoute) + private _router = inject(Router) private readonly _unsubscribeAll$ = new Subject() columnDefs: ColDef[] = [] @@ -79,21 +80,21 @@ export class GroupsComponent implements OnDestroy, OnInit { } nameRenderer = ({ data, value }: { data: InventoryGroup; value: string }) => { - return `${value}` + return `${value}` } actionRenderer = () => { return ` -
- edit - clear +
+ edit + delete
` } setRowData() { this.rowData = [] - for (const group of this.groups) { + for (const group of this.groups ?? []) { const row = { id: group.id, name: group.name, @@ -115,13 +116,13 @@ export class GroupsComponent implements OnDestroy, OnInit { } onCellClicked(event: CellClickedEvent) { - if (event.colDef.field !== 'actions') return - const target = event.event.target as HTMLElement - const action = target.getAttribute('data-action') + const action = target.closest('[data-action]')?.getAttribute('data-action') const { id, name } = event.data as { id: number; name: string } - if (action === 'edit') { + if (action === 'navigate') { + void this._router.navigate([`/${this.type}/groups`, id]) + } else if (action === 'edit') { this.editGroup(id) } else if (action === 'delete') { this.openDeleteModal(id, name) @@ -150,7 +151,7 @@ export class GroupsComponent implements OnDestroy, OnInit { .pipe( filter(Boolean), tap(() => { - this.initPage() + this._groupsService.list(this.orgId) }), ) .subscribe() diff --git a/src/app/modules/inventory-list/groups/modal/form-modal.component.ts b/src/app/modules/inventory-list/groups/modal/form-modal.component.ts index 8748b6c8..a34e116b 100644 --- a/src/app/modules/inventory-list/groups/modal/form-modal.component.ts +++ b/src/app/modules/inventory-list/groups/modal/form-modal.component.ts @@ -91,15 +91,25 @@ export class FormModalComponent implements OnDestroy, OnInit { access_level_instance: this.form.value.access_level_instance, } - this._groupsService.create(this.data.orgId, data as InventoryGroup).subscribe(({ id }) => { - this._dialogRef.close(id) + this._groupsService.create(this.data.orgId, data as InventoryGroup).subscribe({ + next: (group) => { + this._dialogRef.close(group?.id ?? true) + }, + error: () => { + this._dialogRef.close(false) + }, }) } onEdit() { const data = { ...this.data.group, name: this.form.value.name } - this._groupsService.update(this.data.orgId, this.data.id, data).subscribe(({ id }) => { - this._dialogRef.close(id) + this._groupsService.update(this.data.orgId, this.data.id, data).subscribe({ + next: (group) => { + this._dialogRef.close(group?.id ?? true) + }, + error: () => { + this._dialogRef.close(false) + }, }) } diff --git a/src/app/modules/inventory/inventory.routes.ts b/src/app/modules/inventory/inventory.routes.ts index 34558944..5c546695 100644 --- a/src/app/modules/inventory/inventory.routes.ts +++ b/src/app/modules/inventory/inventory.routes.ts @@ -14,6 +14,13 @@ import { UbidsComponent, } from '../inventory-detail' import { ColumnDetailProfilesComponent } from '../inventory-detail/column-detail-profiles/column-detail-profiles.component' +import { GroupDashboardComponent } from '../inventory-list/groups/detail/dashboard/dashboard.component' +import { GroupDetailLayoutComponent } from '../inventory-list/groups/detail/group-detail-layout.component' +import { GroupMapComponent } from '../inventory-list/groups/detail/map/map.component' +import { GroupMetersComponent } from '../inventory-list/groups/detail/meters/meters.component' +import { GroupPropertiesComponent } from '../inventory-list/groups/detail/properties/properties.component' +import { GroupSystemsComponent } from '../inventory-list/groups/detail/systems/systems.component' +import { ServiceDetailComponent } from '../inventory-list/groups/detail/systems/service-detail/service-detail.component' import { SummaryComponent } from '../inventory-list/summary/summary.component' import type { InventoryType } from './inventory.types' @@ -39,6 +46,20 @@ export default [ title: 'Groups', component: GroupsComponent, }, + { + path: 'groups/:groupId', + title: 'Group Detail', + component: GroupDetailLayoutComponent, + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', title: 'Dashboard', component: GroupDashboardComponent }, + { path: 'properties', title: 'Properties', component: GroupPropertiesComponent }, + { path: 'systems', title: 'Systems & Services', component: GroupSystemsComponent }, + { path: 'systems/services/:systemId/:serviceId', title: 'Service Detail', component: ServiceDetailComponent }, + { path: 'meters', title: 'Meters', component: GroupMetersComponent }, + { path: 'map', title: 'Map', component: GroupMapComponent }, + ], + }, { path: 'column-list-profiles', title: 'Column Profiles', From 181913dda87fca28d6ba0ec47c38e7d68c320088 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Thu, 14 May 2026 17:23:13 -0600 Subject: [PATCH 02/36] lint --- src/@seed/api/groups/groups.service.ts | 48 ++++++++-- .../detail/dashboard/dashboard.component.html | 4 +- .../detail/dashboard/dashboard.component.ts | 16 ++-- .../delete-meter-dialog.component.html | 5 +- .../dialogs/delete-meter-dialog.component.ts | 15 ++-- .../dialogs/edit-meter-dialog.component.html | 10 ++- .../dialogs/edit-meter-dialog.component.ts | 15 ++-- .../upload-readings-dialog.component.html | 18 ++-- .../upload-readings-dialog.component.ts | 7 +- .../detail/meters/meters.component.html | 4 +- .../groups/detail/meters/meters.component.ts | 87 +++++++++++-------- .../properties/properties.component.html | 4 +- .../detail/properties/properties.component.ts | 2 +- .../service-detail.component.html | 6 +- .../service-dialog.component.html | 9 +- .../service-dialog.component.ts | 22 +++-- .../system-dialog.component.html | 27 +++--- .../system-dialog/system-dialog.component.ts | 22 +++-- .../detail/systems/systems.component.html | 25 +++--- .../detail/systems/systems.component.ts | 71 +++++++++++---- .../inventory-list/groups/groups.component.ts | 2 +- src/app/modules/inventory/inventory.routes.ts | 2 +- 22 files changed, 257 insertions(+), 164 deletions(-) diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index b3b82e6d..3373e5de 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -6,7 +6,24 @@ import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory' import { OrganizationService } from '../organization' -import type { GroupDashboard, GroupDashboardResponse, GroupMeter, GroupMetersResponse, GroupMeterUsageResponse, GroupPropertiesResponse, GroupProperty, GroupSankeyEntry, GroupSankeyResponse, GroupService, GroupSystem, InventoryGroup, InventoryGroupResponse, InventoryGroupsResponse, MeterInterval, SystemsByTypeResponse } from './groups.types' +import type { + GroupDashboard, + GroupDashboardResponse, + GroupMeter, + GroupMetersResponse, + GroupMeterUsageResponse, + GroupPropertiesResponse, + GroupProperty, + GroupSankeyEntry, + GroupSankeyResponse, + GroupService, + GroupSystem, + InventoryGroup, + InventoryGroupResponse, + InventoryGroupsResponse, + MeterInterval, + SystemsByTypeResponse, +} from './groups.types' @Injectable({ providedIn: 'root' }) export class GroupsService { @@ -281,7 +298,13 @@ export class GroupsService { ) } - updateService(orgId: number, groupId: number, systemId: number, serviceId: number, serviceData: Partial): Observable { + updateService( + orgId: number, + groupId: number, + systemId: number, + serviceId: number, + serviceData: Partial, + ): Observable { const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` return this._httpClient.put(url, serviceData).pipe( tap(() => { @@ -307,7 +330,8 @@ export class GroupsService { getServiceDetail(orgId: number, groupId: number, systemId: number, serviceId: number) { const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` - return this._httpClient.get<{ + return this._httpClient + .get<{ id: number; system_name: string; name: string; @@ -324,14 +348,20 @@ export class GroupsService { meter_type: string; has_meter_data: boolean; }[]; - }>(url).pipe( - catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, 'Error fetching service detail') - }), - ) + }>(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching service detail') + }), + ) } - updateMeter(orgId: number, groupId: number, meterId: number, data: { alias?: string; connection_config?: Record }): Observable { + updateMeter( + orgId: number, + groupId: number, + meterId: number, + data: { alias?: string; connection_config?: Record }, + ): Observable { const url = `/api/v3/inventory_groups/${groupId}/meters/${meterId}/?organization_id=${orgId}` return this._httpClient.put(url, data).pipe( tap(() => { diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html index 0ab14bab..fd041d26 100644 --- a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html @@ -28,11 +28,11 @@
Gross Floor Area
-
{{ (dashboard['Gross Floor Area'] ?? 0) | number }}
+
{{ dashboard['Gross Floor Area'] ?? 0 | number }}
Site EUI
-
{{ (dashboard['Site EUI'] ?? 0) | number }}
+
{{ dashboard['Site EUI'] ?? 0 | number }}
Views Count
diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts index d22cdab2..268dae91 100644 --- a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts @@ -2,8 +2,7 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { catchError, Subject, switchMap, take, takeUntil, tap } from 'rxjs' -import { of } from 'rxjs' +import { catchError, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { GroupDashboard, GroupSankeyEntry, OrgCycle } from '@seed/api' import { GroupsService, OrganizationService } from '@seed/api' import { PageComponent } from '@seed/components' @@ -53,10 +52,9 @@ export class GroupDashboardComponent implements OnDestroy, OnInit { return this._groupsService.getDashboard(this.orgId, this.groupId, this.cycleId).pipe( tap((data) => { this.dashboard = data - this.meterTypes = [ - ...Object.keys(data?.importing_total ?? {}), - ...Object.keys(data?.exporting_total ?? {}), - ].filter((v, i, a) => a.indexOf(v) === i) + this.meterTypes = [...Object.keys(data?.importing_total ?? {}), ...Object.keys(data?.exporting_total ?? {})].filter( + (v, i, a) => a.indexOf(v) === i, + ) if (this.meterTypes.length && !this.meterType) { this.meterType = this.meterTypes[0] } @@ -72,9 +70,9 @@ export class GroupDashboardComponent implements OnDestroy, OnInit { changeCycle(cycleId: number) { this.cycleId = cycleId - this.loadDashboard().pipe( - switchMap(() => this.loadSankey()), - ).subscribe() + this.loadDashboard() + .pipe(switchMap(() => this.loadSankey())) + .subscribe() } loadSankey() { diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html index f35fa81d..774d0f90 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html @@ -1,7 +1,10 @@
Delete Meter
-

Are you sure you want to delete the meter {{ meterName }}?

+

+ Are you sure you want to delete the meter {{ meterName }}? +

This action cannot be undone. All associated meter readings will also be deleted.

diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts index 4e2e728e..589fbb3b 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts @@ -30,12 +30,13 @@ export class DeleteMeterDialogComponent { if (this.submitted) return this.submitted = true - this._groupsService.deleteMeter(this._data.orgId, this._data.groupId, this._data.meter.id) - .subscribe({ - next: () => this._dialogRef.close(true), - error: () => { - this.submitted = false - }, - }) + this._groupsService.deleteMeter(this._data.orgId, this._data.groupId, this._data.meter.id).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) } } diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html index 1fc9a7db..a40994d9 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html @@ -6,7 +6,7 @@ Alias - + @@ -34,9 +34,11 @@ Meter Usage - info + info - + @for (option of useOptions; track option.value) { {{ option.display }} } @@ -47,7 +49,7 @@ @if (use) { System - + @for (system of systemOptions; track system.id) { {{ system.name }} } diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts index 0e49cbb4..f614fa61 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts @@ -130,12 +130,13 @@ export class EditMeterDialogComponent implements OnInit { payload.alias = this.alias } - this._groupsService.updateMeter(this._data.orgId, this._data.groupId, this._data.meter.id, payload) - .subscribe({ - next: () => this._dialogRef.close(true), - error: () => { - this.submitted = false - }, - }) + this._groupsService.updateMeter(this._data.orgId, this._data.groupId, this._data.meter.id, payload).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) } } diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html index 6123b457..143180eb 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html @@ -4,29 +4,25 @@ @if (state === 'upload') {
-

- Upload a CSV or XLSX file with columns: Start Date, End Date, Usage Units, Reading. -

+

Upload a CSV or XLSX file with columns: Start Date, End Date, Usage Units, Reading.

- - +
@if (selectedFile) {
- + {{ selectedFile.name }}
} @if (invalidExtension) { -
- Invalid file type. Please upload a CSV or XLSX file. -
+
Invalid file type. Please upload a CSV or XLSX file.
}
} @@ -48,9 +44,7 @@
@if (state === 'upload') { - + } @else if (state === 'confirmation') { } diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts index b7d6f90c..e58ba405 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts @@ -1,6 +1,6 @@ +import { HttpClient } from '@angular/common/http' import type { OnInit } from '@angular/core' import { Component, inject } from '@angular/core' -import { HttpClient } from '@angular/common/http' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { catchError, of, switchMap } from 'rxjs' import { GroupsService, OrganizationService } from '@seed/api' @@ -64,7 +64,8 @@ export class UploadReadingsDialogComponent implements OnInit { const datasetName = `Meter Readings Upload - ${new Date().toISOString()}` // Step 1: Create dataset - this._httpClient.post<{ id: number }>(`/api/v3/datasets/?organization_id=${this.orgId}`, { name: datasetName }) + this._httpClient + .post<{ id: number }>(`/api/v3/datasets/?organization_id=${this.orgId}`, { name: datasetName }) .pipe( // Step 2: Upload file switchMap((dataset) => { @@ -78,7 +79,7 @@ export class UploadReadingsDialogComponent implements OnInit { switchMap((uploadResult) => { return this._groupsService.uploadMeterReadings(this.orgId, uploadResult.import_file_id, this._data.meter.id) }), - catchError((error) => { + catchError((error: { error?: { message?: string }; message?: string }) => { const message = error?.error?.message || error?.message || 'Upload failed' return of({ message: `Failure: ${message}` }) }), diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.html b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html index 9ce340e8..6ab56959 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/meters.component.html +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html @@ -19,11 +19,11 @@ } @else { } @@ -48,10 +48,10 @@ } @else if (readings.length) { }
diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts index fcb09a03..6d4decf3 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts @@ -1,7 +1,7 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' import { MatDialog } from '@angular/material/dialog' +import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, ColDef } from 'ag-grid-community' import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community' @@ -10,12 +10,12 @@ import type { GroupMeter, MeterInterval } from '@seed/api' import { GroupsService, OrganizationService } from '@seed/api' import { PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' -import { EditMeterDialogComponent } from './dialogs/edit-meter-dialog.component' -import type { EditMeterDialogData } from './dialogs/edit-meter-dialog.component' -import { DeleteMeterDialogComponent } from './dialogs/delete-meter-dialog.component' import type { DeleteMeterDialogData } from './dialogs/delete-meter-dialog.component' -import { UploadReadingsDialogComponent } from './dialogs/upload-readings-dialog.component' +import { DeleteMeterDialogComponent } from './dialogs/delete-meter-dialog.component' +import type { EditMeterDialogData } from './dialogs/edit-meter-dialog.component' +import { EditMeterDialogComponent } from './dialogs/edit-meter-dialog.component' import type { UploadReadingsDialogData } from './dialogs/upload-readings-dialog.component' +import { UploadReadingsDialogComponent } from './dialogs/upload-readings-dialog.component' ModuleRegistry.registerModules([AllCommunityModule]) @@ -123,25 +123,24 @@ export class GroupMetersComponent implements OnDestroy, OnInit { loadReadings() { this.loadingReadings = true - this._groupsService.getMeterUsage(this.orgId, this.groupId, this.interval) - .subscribe({ - next: (data) => { - this.readings = data.readings - this.readingColumnDefs = data.column_defs.map((col) => ({ - headerName: col.headerName ?? col.field, - field: col.field, - flex: 1, - sortable: true, - filter: true, - })) - this.loadingReadings = false - }, - error: () => { - this.readings = [] - this.readingColumnDefs = [] - this.loadingReadings = false - }, - }) + this._groupsService.getMeterUsage(this.orgId, this.groupId, this.interval).subscribe({ + next: (data) => { + this.readings = data.readings + this.readingColumnDefs = data.column_defs.map((col) => ({ + headerName: col.headerName ?? col.field, + field: col.field, + flex: 1, + sortable: true, + filter: true, + })) + this.loadingReadings = false + }, + error: () => { + this.readings = [] + this.readingColumnDefs = [] + this.loadingReadings = false + }, + }) } changeInterval(interval: MeterInterval) { @@ -167,12 +166,12 @@ export class GroupMetersComponent implements OnDestroy, OnInit { break case 'navigate-property': if (meter.view_id) { - this._router.navigate(['/', this.inventoryType, meter.view_id, 'meters']) + void this._router.navigate(['/', this.inventoryType, meter.view_id, 'meters']) } break case 'navigate-connection': if (meter.service_group) { - this._router.navigate(['/', this.inventoryType, 'groups', meter.service_group, 'systems']) + void this._router.navigate(['/', this.inventoryType, 'groups', meter.service_group, 'systems']) } break } @@ -180,38 +179,50 @@ export class GroupMetersComponent implements OnDestroy, OnInit { editMeter(meter: GroupMeter) { const data: EditMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } - this._dialog.open(EditMeterDialogComponent, { data, width: '500px' }) + this._dialog + .open(EditMeterDialogComponent, { data, width: '500px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.refreshMeters())) + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) .subscribe() } deleteMeter(meter: GroupMeter) { const data: DeleteMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } - this._dialog.open(DeleteMeterDialogComponent, { data, width: '400px' }) + this._dialog + .open(DeleteMeterDialogComponent, { data, width: '400px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.refreshMeters())) + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) .subscribe() } addReadings(meter: GroupMeter) { const data: UploadReadingsDialogData = { orgId: this.orgId, groupId: this.groupId, meter } - this._dialog.open(UploadReadingsDialogComponent, { data, width: '500px' }) + this._dialog + .open(UploadReadingsDialogComponent, { data, width: '500px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.refreshMeters())) + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) .subscribe() } - private refreshMeters() { + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + private _refreshMeters() { return this._groupsService.getMeters(this.orgId, this.groupId).pipe( tap((data) => { this.meters = data }), ) } - - ngOnDestroy() { - this._unsubscribeAll$.next() - this._unsubscribeAll$.complete() - } } diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.html b/src/app/modules/inventory-list/groups/detail/properties/properties.component.html index 6d9c22bc..eaeb577e 100644 --- a/src/app/modules/inventory-list/groups/detail/properties/properties.component.html +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.html @@ -12,16 +12,16 @@ } @else if (properties.length === 0) {
No properties in this group. Properties can be added from the - Inventory → Properties page. + Inventory → Properties page.
} @else { }
diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts index 6ff78b0b..1d4d923b 100644 --- a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts @@ -33,7 +33,7 @@ export class GroupPropertiesComponent implements OnDestroy, OnInit { headerName: 'Property Name', field: 'property_display_name', flex: 1, - valueGetter: ({ data }) => data?.property_display_name || `Property ${data?.property_id}`, + valueGetter: ({ data }: { data: GroupProperty }) => data?.property_display_name || `Property ${data?.property_id}`, }, { headerName: 'Property ID', diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html index d8a0ea5d..ba92ba79 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html @@ -9,7 +9,7 @@
- @@ -38,12 +38,12 @@ @for (prop of service.properties; track prop.property_id) { - + {{ prop.property_display_name || 'Property ' + prop.property_id }} - + {{ prop.meter_alias || 'Meter ' + prop.meter_id }} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html index 2f8d8155..7f259e67 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html @@ -3,17 +3,20 @@
{{ title }}
@if (action === 'delete') { -

Are you sure you want to delete the service {{ serviceName }}?

+

+ Are you sure you want to delete the service {{ serviceName }}? +

} @else {
Service Name - + Emission Factor - +
} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts index a2a1b075..a4073b7d 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts @@ -43,22 +43,26 @@ export class ServiceDialogComponent { } const serviceId = this._data.service?.id - const obs = this.action === 'create' - ? this._groupsService.createService(this._data.orgId, this._data.groupId, this._data.systemId, payload) - : this._groupsService.updateService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId, payload) + const obs + = this.action === 'create' + ? this._groupsService.createService(this._data.orgId, this._data.groupId, this._data.systemId, payload) + : this._groupsService.updateService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId, payload) - obs.pipe( - finalize(() => { - this._dialogRef.close(true) - }), - ).subscribe() + obs + .pipe( + finalize(() => { + this._dialogRef.close(true) + }), + ) + .subscribe() } deleteService() { if (this.submitted) return this.submitted = true const serviceId = this._data.service?.id - this._groupsService.deleteService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId) + this._groupsService + .deleteService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId) .pipe( finalize(() => { this._dialogRef.close(true) diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html index 35757f65..6473ec82 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html @@ -3,13 +3,16 @@
{{ title }}
@if (action === 'delete') { -

Are you sure you want to delete the system {{ systemName }}?

+

+ Are you sure you want to delete the system {{ systemName }}? +

} @else {
System Name - + @@ -38,20 +41,20 @@ @if (desType !== 'Chiller') { Heating Capacity (MMBtu) - + } @if (desType === 'Chiller' || desType === 'CHP') { Cooling Capacity (Ton) - + } Count - + } @@ -68,17 +71,17 @@ Power (kW) - + Voltage (V) - + Count - + } @@ -86,22 +89,22 @@ @if (systemType === 'Battery') { Efficiency (%) - + Power Capacity (kW) - + Energy Capacity (kWh) - + Voltage (V) - + }
diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts index 1ea6e1df..cfc31ae9 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts @@ -79,22 +79,26 @@ export class SystemDialogComponent { } const systemId = this._data.system?.id - const obs = this.action === 'create' - ? this._groupsService.createSystem(this._data.orgId, this._data.groupId, payload) - : this._groupsService.updateSystem(this._data.orgId, this._data.groupId, systemId, payload) + const obs + = this.action === 'create' + ? this._groupsService.createSystem(this._data.orgId, this._data.groupId, payload) + : this._groupsService.updateSystem(this._data.orgId, this._data.groupId, systemId, payload) - obs.pipe( - finalize(() => { - this._dialogRef.close(true) - }), - ).subscribe() + obs + .pipe( + finalize(() => { + this._dialogRef.close(true) + }), + ) + .subscribe() } deleteSystem() { if (this.submitted) return this.submitted = true const systemId = this._data.system?.id - this._groupsService.deleteSystem(this._data.orgId, this._data.groupId, systemId) + this._groupsService + .deleteSystem(this._data.orgId, this._data.groupId, systemId) .pipe( finalize(() => { this._dialogRef.close(true) diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.html b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html index ec608c92..1a017de7 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/systems.component.html +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html @@ -9,7 +9,7 @@
- @@ -60,9 +60,12 @@ @if (system.services?.length) { - } @@ -94,13 +97,13 @@ {{ system.services?.length ?? 0 }} {{ (system.services?.length ?? 0) === 1 ? 'service' : 'services' }} - - -
@@ -109,7 +112,7 @@ @if (expandedSystems.has(system.id) && system.services?.length) { - + @@ -124,13 +127,13 @@ diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts b/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts index af01cda2..3b6a2a98 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts @@ -59,58 +59,93 @@ export class GroupSystemsComponent implements OnDestroy, OnInit { createSystem() { const data: SystemDialogData = { action: 'create', orgId: this.orgId, groupId: this.groupId } - this._dialog.open(SystemDialogComponent, { data, width: '500px' }) + this._dialog + .open(SystemDialogComponent, { data, width: '500px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) .subscribe() } editSystem(system: GroupSystem) { const data: SystemDialogData = { action: 'edit', orgId: this.orgId, groupId: this.groupId, system } - this._dialog.open(SystemDialogComponent, { data, width: '500px' }) + this._dialog + .open(SystemDialogComponent, { data, width: '500px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) .subscribe() } deleteSystem(system: GroupSystem) { const data: SystemDialogData = { action: 'delete', orgId: this.orgId, groupId: this.groupId, system } - this._dialog.open(SystemDialogComponent, { data, width: '400px' }) + this._dialog + .open(SystemDialogComponent, { data, width: '400px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) .subscribe() } createService(system: GroupSystem) { const data: ServiceDialogData = { - action: 'create', orgId: this.orgId, groupId: this.groupId, - systemId: system.id, systemName: system.name, + action: 'create', + orgId: this.orgId, + groupId: this.groupId, + systemId: system.id, + systemName: system.name, } - this._dialog.open(ServiceDialogComponent, { data, width: '450px' }) + this._dialog + .open(ServiceDialogComponent, { data, width: '450px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) .subscribe() } editService(system: GroupSystem, service: GroupService) { const data: ServiceDialogData = { - action: 'edit', orgId: this.orgId, groupId: this.groupId, - systemId: system.id, systemName: system.name, service, + action: 'edit', + orgId: this.orgId, + groupId: this.groupId, + systemId: system.id, + systemName: system.name, + service, } - this._dialog.open(ServiceDialogComponent, { data, width: '450px' }) + this._dialog + .open(ServiceDialogComponent, { data, width: '450px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) .subscribe() } deleteService(system: GroupSystem, service: GroupService) { const data: ServiceDialogData = { - action: 'delete', orgId: this.orgId, groupId: this.groupId, - systemId: system.id, systemName: system.name, service, + action: 'delete', + orgId: this.orgId, + groupId: this.groupId, + systemId: system.id, + systemName: system.name, + service, } - this._dialog.open(ServiceDialogComponent, { data, width: '400px' }) + this._dialog + .open(ServiceDialogComponent, { data, width: '400px' }) .afterClosed() - .pipe(filter(Boolean), switchMap(() => this.loadSystems())) + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) .subscribe() } diff --git a/src/app/modules/inventory-list/groups/groups.component.ts b/src/app/modules/inventory-list/groups/groups.component.ts index 036375b1..b28c8f9a 100644 --- a/src/app/modules/inventory-list/groups/groups.component.ts +++ b/src/app/modules/inventory-list/groups/groups.component.ts @@ -79,7 +79,7 @@ export class GroupsComponent implements OnDestroy, OnInit { ] } - nameRenderer = ({ data, value }: { data: InventoryGroup; value: string }) => { + nameRenderer = ({ value }: { data: InventoryGroup; value: string }) => { return `${value}` } diff --git a/src/app/modules/inventory/inventory.routes.ts b/src/app/modules/inventory/inventory.routes.ts index 5c546695..7cdc4bc4 100644 --- a/src/app/modules/inventory/inventory.routes.ts +++ b/src/app/modules/inventory/inventory.routes.ts @@ -19,8 +19,8 @@ import { GroupDetailLayoutComponent } from '../inventory-list/groups/detail/grou import { GroupMapComponent } from '../inventory-list/groups/detail/map/map.component' import { GroupMetersComponent } from '../inventory-list/groups/detail/meters/meters.component' import { GroupPropertiesComponent } from '../inventory-list/groups/detail/properties/properties.component' -import { GroupSystemsComponent } from '../inventory-list/groups/detail/systems/systems.component' import { ServiceDetailComponent } from '../inventory-list/groups/detail/systems/service-detail/service-detail.component' +import { GroupSystemsComponent } from '../inventory-list/groups/detail/systems/systems.component' import { SummaryComponent } from '../inventory-list/summary/summary.component' import type { InventoryType } from './inventory.types' From 62cb2c8e22c293e530a6e0bf49f3f22c6b12ba5a Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Fri, 15 May 2026 10:05:34 -0600 Subject: [PATCH 03/36] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../groups/detail/meters/meters.component.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts index 6d4decf3..88026793 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts @@ -33,8 +33,17 @@ export class GroupMetersComponent implements OnDestroy, OnInit { private _dialog = inject(MatDialog) + private _getInventoryType(): string { + return ( + [...this._route.pathFromRoot] + .reverse() + .map(route => route.snapshot.paramMap.get('type')) + .find((type): type is string => !!type) ?? 'properties' + ) + } + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) - inventoryType = this._route.parent?.parent?.parent?.snapshot?.url?.[0]?.path ?? 'properties' + inventoryType = this._getInventoryType() orgId: number meters: GroupMeter[] = [] readings: Record[] = [] From fae1efa35a01807247ac80072075f24fc9fa5e38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:09:25 +0000 Subject: [PATCH 04/36] fix: unwrap inventory group API responses Agent-Logs-Url: https://github.com/SEED-platform/seed-angular/sessions/cca1a734-0ec0-4fd1-b61a-5e77446aaa4c Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- src/@seed/api/groups/groups.service.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index 3373e5de..02e15921 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -38,10 +38,10 @@ export class GroupsService { list(orgId: number) { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` this._httpClient - .get(url) + .get(url) .pipe( take(1), - map((data) => { + map(({ data }) => { const groups = Array.isArray(data) ? data : [] this._groups.next(groups) return groups @@ -74,8 +74,8 @@ export class GroupsService { fetchGroups(orgId: number): Observable { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` - return this._httpClient.get(url).pipe( - map((data) => (Array.isArray(data) ? data : [])), + return this._httpClient.get(url).pipe( + map(({ data }) => (Array.isArray(data) ? data : [])), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching groups') }), @@ -84,11 +84,11 @@ export class GroupsService { create(orgId: number, data: InventoryGroup): Observable { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` - return this._httpClient.post(url, data).pipe( - map((group) => { + return this._httpClient.post(url, data).pipe( + map(({ data }) => { this._snackBar.success('Group created successfully') this.list(orgId) - return group + return data }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating group') @@ -98,7 +98,8 @@ export class GroupsService { get(orgId: number, id: number): Observable { const url = `/api/v3/inventory_groups/${id}/?organization_id=${orgId}` - return this._httpClient.get(url).pipe( + return this._httpClient.get(url).pipe( + map(({ data }) => data), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching group') }), @@ -107,11 +108,11 @@ export class GroupsService { update(orgId: number, id: number, data: InventoryGroup): Observable { const url = `/api/v3/inventory_groups/${id}/?organization_id=${orgId}` - return this._httpClient.put(url, data).pipe( - map((group) => { + return this._httpClient.put(url, data).pipe( + map(({ data }) => { this._snackBar.success('Group updated successfully') this.list(orgId) - return group + return data }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating group') From 6d5ba86ccb1917008f422fa395cf1e4ed4ba3d30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:12:19 +0000 Subject: [PATCH 05/36] fix: correct group create error message Agent-Logs-Url: https://github.com/SEED-platform/seed-angular/sessions/cca1a734-0ec0-4fd1-b61a-5e77446aaa4c Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- src/@seed/api/groups/groups.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index 02e15921..ed685c27 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -91,7 +91,7 @@ export class GroupsService { return data }), catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, 'Error updating group') + return this._errorService.handleError(error, 'Error creating group') }), ) } From 0b2c26fac85b0d168647e31c72dca5008e341206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:16:56 +0000 Subject: [PATCH 06/36] fix: use explicit error handlers in system/service dialogs and add org filter to dashboard Agent-Logs-Url: https://github.com/SEED-platform/seed-angular/sessions/8689fb73-1d01-4af9-b641-d5799b1b7597 Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- .../detail/dashboard/dashboard.component.ts | 3 +- .../service-dialog.component.ts | 32 +++++++++---------- .../system-dialog/system-dialog.component.ts | 32 +++++++++---------- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts index 268dae91..9da225fe 100644 --- a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { catchError, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import { catchError, filter, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { GroupDashboard, GroupSankeyEntry, OrgCycle } from '@seed/api' import { GroupsService, OrganizationService } from '@seed/api' import { PageComponent } from '@seed/components' @@ -33,6 +33,7 @@ export class GroupDashboardComponent implements OnDestroy, OnInit { this._organizationService.currentOrganization$ .pipe( takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), take(1), tap(({ org_id }) => { this.orgId = org_id diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts index a4073b7d..399fe3e5 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts @@ -1,7 +1,6 @@ import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import { finalize } from 'rxjs' import { GroupsService } from '@seed/api' import { MaterialImports } from '@seed/materials' import type { ServiceDialogData } from '../dialog-types' @@ -48,26 +47,27 @@ export class ServiceDialogComponent { ? this._groupsService.createService(this._data.orgId, this._data.groupId, this._data.systemId, payload) : this._groupsService.updateService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId, payload) - obs - .pipe( - finalize(() => { - this._dialogRef.close(true) - }), - ) - .subscribe() + obs.subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) } deleteService() { if (this.submitted) return this.submitted = true const serviceId = this._data.service?.id - this._groupsService - .deleteService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId) - .pipe( - finalize(() => { - this._dialogRef.close(true) - }), - ) - .subscribe() + this._groupsService.deleteService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) } } diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts index cfc31ae9..cf3fe4ca 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts @@ -1,7 +1,6 @@ import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import { finalize } from 'rxjs' import type { DesType, EvseType, GroupSystem, SystemType } from '@seed/api' import { GroupsService } from '@seed/api' import { MaterialImports } from '@seed/materials' @@ -84,26 +83,27 @@ export class SystemDialogComponent { ? this._groupsService.createSystem(this._data.orgId, this._data.groupId, payload) : this._groupsService.updateSystem(this._data.orgId, this._data.groupId, systemId, payload) - obs - .pipe( - finalize(() => { - this._dialogRef.close(true) - }), - ) - .subscribe() + obs.subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) } deleteSystem() { if (this.submitted) return this.submitted = true const systemId = this._data.system?.id - this._groupsService - .deleteSystem(this._data.orgId, this._data.groupId, systemId) - .pipe( - finalize(() => { - this._dialogRef.close(true) - }), - ) - .subscribe() + this._groupsService.deleteSystem(this._data.orgId, this._data.groupId, systemId).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) } } From 8fc41295f66844785b7581396ef19d7671fae895 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Fri, 15 May 2026 10:17:31 -0600 Subject: [PATCH 07/36] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../groups/detail/properties/properties.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts index 1d4d923b..c61acc7d 100644 --- a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts @@ -66,8 +66,8 @@ export class GroupPropertiesComponent implements OnDestroy, OnInit { .subscribe() } - onRowClicked(event: { data: GroupProperty }) { - void this._router.navigate(['/properties', event.data.property_id]) + onRowClicked(event: { data: GroupProperty & { property_view_id: number } }) { + void this._router.navigate(['/properties', event.data.property_view_id]) } ngOnDestroy() { From dda8454cc1bace474d08cbda3128ad2106b7aa02 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Fri, 15 May 2026 10:18:16 -0600 Subject: [PATCH 08/36] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/app/modules/inventory-list/groups/groups.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/inventory-list/groups/groups.component.ts b/src/app/modules/inventory-list/groups/groups.component.ts index b28c8f9a..0449145f 100644 --- a/src/app/modules/inventory-list/groups/groups.component.ts +++ b/src/app/modules/inventory-list/groups/groups.component.ts @@ -151,7 +151,7 @@ export class GroupsComponent implements OnDestroy, OnInit { .pipe( filter(Boolean), tap(() => { - this._groupsService.list(this.orgId) + this.initPage() }), ) .subscribe() From 38e62cc11da48a2bd6764b3b66daddc8a297eff9 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Fri, 15 May 2026 10:18:33 -0600 Subject: [PATCH 09/36] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .spelling.dic | 1 - 1 file changed, 1 deletion(-) diff --git a/.spelling.dic b/.spelling.dic index 518ce85f..fd9d6025 100644 --- a/.spelling.dic +++ b/.spelling.dic @@ -80,4 +80,3 @@ xmark csrftoken sankey evse -EVSE From a745a56a09070cfdb21f2fddb7d0fb9476fbdbbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:19:03 +0000 Subject: [PATCH 10/36] fix: improve org_id filter to handle falsy numeric IDs Agent-Logs-Url: https://github.com/SEED-platform/seed-angular/sessions/8689fb73-1d01-4af9-b641-d5799b1b7597 Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- .../groups/detail/dashboard/dashboard.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts index 9da225fe..3c39f85e 100644 --- a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts @@ -33,7 +33,7 @@ export class GroupDashboardComponent implements OnDestroy, OnInit { this._organizationService.currentOrganization$ .pipe( takeUntil(this._unsubscribeAll$), - filter((org) => Boolean(org?.org_id)), + filter((org) => org?.org_id != null), take(1), tap(({ org_id }) => { this.orgId = org_id From cb28df8b19c09014486dc0d5b8435fc95cbe41f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:23:55 +0000 Subject: [PATCH 11/36] fix: improve inventoryType derivation and add proper typing for service detail Agent-Logs-Url: https://github.com/SEED-platform/seed-angular/sessions/e129cbf8-e725-4027-aeaa-40b98f7c4c1d Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- src/@seed/api/groups/groups.service.ts | 30 ++++--------------- src/@seed/api/groups/groups.types.ts | 19 ++++++++++++ .../detail/dashboard/dashboard.component.ts | 5 +++- .../service-detail.component.ts | 22 +++++++------- 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index ed685c27..221cbe97 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -17,6 +17,7 @@ import type { GroupSankeyEntry, GroupSankeyResponse, GroupService, + GroupServiceDetail, GroupSystem, InventoryGroup, InventoryGroupResponse, @@ -331,30 +332,11 @@ export class GroupsService { getServiceDetail(orgId: number, groupId: number, systemId: number, serviceId: number) { const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` - return this._httpClient - .get<{ - id: number; - system_name: string; - name: string; - service_meters: { - in: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; - out: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; - }; - properties: { - property_id: number; - property_view_id: number; - property_display_name: string; - meter_id: number; - meter_alias: string; - meter_type: string; - has_meter_data: boolean; - }[]; - }>(url) - .pipe( - catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, 'Error fetching service detail') - }), - ) + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching service detail') + }), + ) } updateMeter( diff --git a/src/@seed/api/groups/groups.types.ts b/src/@seed/api/groups/groups.types.ts index 940650e5..2c8d997c 100644 --- a/src/@seed/api/groups/groups.types.ts +++ b/src/@seed/api/groups/groups.types.ts @@ -139,3 +139,22 @@ export type GroupMeter = { } export type MeterInterval = 'Exact' | 'Month' | 'Year' + +export type GroupServiceDetail = { + id: number; + system_name: string; + name: string; + service_meters: { + in: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; + out: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; + }; + properties: { + property_id: number; + property_view_id: number; + property_display_name: string; + meter_id: number; + meter_alias: string; + meter_type: string; + has_meter_data: boolean; + }[]; +} diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts index 3c39f85e..136fb745 100644 --- a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts @@ -77,7 +77,10 @@ export class GroupDashboardComponent implements OnDestroy, OnInit { } loadSankey() { - if (!this.meterType) return this._groupsService.getSankeyData(this.orgId, this.groupId, this.cycleId, '') + if (!this.meterType) { + this.sankeyData = [] + return of([]) + } return this._groupsService.getSankeyData(this.orgId, this.groupId, this.cycleId, this.meterType).pipe( tap((data) => { this.sankeyData = data diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts index 19389b60..4e6c885c 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts @@ -2,9 +2,11 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { ActivatedRoute, Router, RouterLink } from '@angular/router' import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupServiceDetail } from '@seed/api' import { GroupsService, OrganizationService } from '@seed/api' import { PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory/inventory.types' @Component({ selector: 'seed-service-detail', @@ -22,30 +24,26 @@ export class ServiceDetailComponent implements OnDestroy, OnInit { systemId: number serviceId: number orgId: number - inventoryType: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - service: any = null + inventoryType: InventoryType + service: GroupServiceDetail | null = null loading = true ngOnInit() { this.systemId = parseInt(this._route.snapshot.paramMap.get('systemId')) this.serviceId = parseInt(this._route.snapshot.paramMap.get('serviceId')) - // Walk up to find groupId from parent routes - let route = this._route.parent - while (route) { + // Walk up to find groupId and type from parent routes using pathFromRoot + for (const route of this._route.pathFromRoot) { const gid = route.snapshot.paramMap.get('groupId') if (gid) { this.groupId = parseInt(gid) - break } - route = route.parent + const type = route.snapshot.paramMap.get('type') + if (type) { + this.inventoryType = type as InventoryType + } } - // Get inventory type from URL - const urlParts = this._router.url.split('/') - this.inventoryType = urlParts.find((p) => p === 'properties' || p === 'taxlots') ?? 'properties' - this._organizationService.currentOrganization$ .pipe( takeUntil(this._unsubscribeAll$), From 4c2f87ae6174b44e56585a2291463ab7a898368f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:26:11 +0000 Subject: [PATCH 12/36] fix: preserve first-found behavior in pathFromRoot loop Agent-Logs-Url: https://github.com/SEED-platform/seed-angular/sessions/e129cbf8-e725-4027-aeaa-40b98f7c4c1d Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- .../service-detail/service-detail.component.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts index 4e6c885c..669dca91 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts @@ -34,13 +34,17 @@ export class ServiceDetailComponent implements OnDestroy, OnInit { // Walk up to find groupId and type from parent routes using pathFromRoot for (const route of this._route.pathFromRoot) { - const gid = route.snapshot.paramMap.get('groupId') - if (gid) { - this.groupId = parseInt(gid) + if (!this.groupId) { + const gid = route.snapshot.paramMap.get('groupId') + if (gid) { + this.groupId = parseInt(gid) + } } - const type = route.snapshot.paramMap.get('type') - if (type) { - this.inventoryType = type as InventoryType + if (!this.inventoryType) { + const type = route.snapshot.paramMap.get('type') + if (type) { + this.inventoryType = type as InventoryType + } } } From b07dbb8ba2dd629c94d059ce92f64075041f859f Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Fri, 15 May 2026 15:01:38 -0600 Subject: [PATCH 13/36] lint --- .../groups/detail/meters/meters.component.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts index 88026793..959c2aad 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts @@ -30,18 +30,8 @@ export class GroupMetersComponent implements OnDestroy, OnInit { private _route = inject(ActivatedRoute) private _router = inject(Router) private readonly _unsubscribeAll$ = new Subject() - private _dialog = inject(MatDialog) - private _getInventoryType(): string { - return ( - [...this._route.pathFromRoot] - .reverse() - .map(route => route.snapshot.paramMap.get('type')) - .find((type): type is string => !!type) ?? 'properties' - ) - } - groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) inventoryType = this._getInventoryType() orgId: number @@ -227,6 +217,15 @@ export class GroupMetersComponent implements OnDestroy, OnInit { this._unsubscribeAll$.complete() } + private _getInventoryType(): string { + return ( + [...this._route.pathFromRoot] + .reverse() + .map((route) => route.snapshot.paramMap.get('type')) + .find((type): type is string => !!type) ?? 'properties' + ) + } + private _refreshMeters() { return this._groupsService.getMeters(this.orgId, this.groupId).pipe( tap((data) => { From 49d5a06d1e03009434c321190de5223aaa88b864 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Sat, 16 May 2026 09:08:25 -0600 Subject: [PATCH 14/36] fix inventory groups --- src/@seed/api/groups/groups.service.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index 221cbe97..289f151e 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -21,7 +21,6 @@ import type { GroupSystem, InventoryGroup, InventoryGroupResponse, - InventoryGroupsResponse, MeterInterval, SystemsByTypeResponse, } from './groups.types' @@ -39,10 +38,10 @@ export class GroupsService { list(orgId: number) { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` this._httpClient - .get(url) + .get(url) .pipe( take(1), - map(({ data }) => { + map((data) => { const groups = Array.isArray(data) ? data : [] this._groups.next(groups) return groups @@ -59,12 +58,13 @@ export class GroupsService { const url = `/api/v3/inventory_groups/filter/?organization_id=${orgId}&inventory_type=${type}` const body = { selected: inventoryIds } this._httpClient - .post(url, body) + .post(url, body) .pipe( take(1), - map(({ data }) => { - this._groups.next(data) - return data + map((data) => { + const groups = Array.isArray(data) ? data : [] + this._groups.next(groups) + return groups }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching groups for inventory') @@ -75,8 +75,8 @@ export class GroupsService { fetchGroups(orgId: number): Observable { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` - return this._httpClient.get(url).pipe( - map(({ data }) => (Array.isArray(data) ? data : [])), + return this._httpClient.get(url).pipe( + map((data) => (Array.isArray(data) ? data : [])), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching groups') }), From a1c56ff02e08eac014834615c2e16e8577acbd9d Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Sat, 16 May 2026 09:09:10 -0600 Subject: [PATCH 15/36] fix import data mapping table --- .../components/ag-grid/autocomplete.component.ts | 3 --- .../data-mappings/step1/map-data.component.ts | 11 +---------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts index ff435a54..4c568aa9 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.ts +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -24,12 +24,9 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV this.inputCtrl.setValue(params.value as string) this.filteredOptions = [...this.options] this.inputCtrl.valueChanges.subscribe((value) => { - // autocomplete this.filteredOptions = this.options.filter((option) => { return option.toLowerCase().startsWith(value.toLowerCase()) }) - // update after each keystroke - this.params.node.setDataValue(this.params.column.getId(), value) }) } diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index ad24c570..a1ccedf0 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -209,18 +209,9 @@ export class MapDataComponent implements OnChanges, OnDestroy { } copyHeadersToSeed() { - const { suggested_column_mappings } = this.mappingSuggestions - const columns = this.getColumns() - const columnMap: Record = columns.reduce( - (acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), - {}, - ) - this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { const fileHeader = node.data.from_field - const suggestedColumnName = suggested_column_mappings[fileHeader][1] - const displayName = columnMap[suggestedColumnName] ?? fileHeader - node.setDataValue('to_field_display_name', displayName) + node.setDataValue('to_field_display_name', fileHeader) }) } From 4426e527475c508f6b97ba2bafea56ccfd399253 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Sat, 16 May 2026 10:51:26 -0600 Subject: [PATCH 16/36] fixes for applying and deleting column list profiles --- proxy.conf.mjs | 6 ++++ src/@seed/api/inventory/inventory.service.ts | 9 +++++- .../column-profiles.component.ts | 10 +++++-- .../list/grid/actions.component.ts | 6 ++-- .../list/inventory.component.html | 2 +- .../list/inventory.component.ts | 30 +++++++++++++++---- 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/proxy.conf.mjs b/proxy.conf.mjs index 71578d86..6f7a578d 100644 --- a/proxy.conf.mjs +++ b/proxy.conf.mjs @@ -13,6 +13,12 @@ export default { proxyReq.setHeader('origin', target) proxyReq.setHeader('referer', `${target}/`) }, + onProxyRes: (proxyRes) => { + // Fix http-proxy mangling 204 No Content responses + if (proxyRes.statusCode === 204) { + proxyRes.headers['content-length'] = '0' + } + }, }, '/media/': { target: process.env.SEED_HOST ?? 'http://127.0.0.1:8000', diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index c9f9a8b1..e7dee50a 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -2,7 +2,7 @@ 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, throwError } from 'rxjs' +import { BehaviorSubject, catchError, map, of, take, tap, throwError } from 'rxjs' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { @@ -144,7 +144,14 @@ export class InventoryService { deleteColumnListProfile(orgId: number, id: number): Observable { const url = `/api/v3/column_list_profiles/${id}/?organization_id=${orgId}` return this._httpClient.delete(url).pipe( + map(() => null), catchError((error: HttpErrorResponse) => { + // Django dev server's 204 No Content causes a Node.js proxy parse error + // (ERR_CONTENT_LENGTH_MISMATCH / status 0). The delete succeeds on the backend, + // so treat proxy parse errors as success. + if (error.status === 0 || error.status === 500) { + return of(null) + } return this._errorService.handleError(error, 'Error deleting column list profile') }), ) diff --git a/src/@seed/components/column-profiles/column-profiles.component.ts b/src/@seed/components/column-profiles/column-profiles.component.ts index efe94a3c..c2029643 100644 --- a/src/@seed/components/column-profiles/column-profiles.component.ts +++ b/src/@seed/components/column-profiles/column-profiles.component.ts @@ -44,6 +44,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { updateCLP$ = new Subject() updateOrgUserSettings$ = new Subject() rowData: ProfileColumn[] = [] + private _rowSelectedTimer: ReturnType gridOptions: GridOptions = { rowSelection: { @@ -241,10 +242,15 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { } onRowSelected(event: RowSelectedEvent) { - if (event.source !== 'api') { + // Ignore programmatic selection and header checkbox intermediate events + if (event.source === 'api') return + + // Defer to let AG Grid finish processing all row selections (e.g., header checkbox "select all") + if (this._rowSelectedTimer) clearTimeout(this._rowSelectedTimer) + this._rowSelectedTimer = setTimeout(() => { const selectedRows = new Set(this.gridApi.getSelectedRows().map((r: ProfileColumn) => r.id)) this.setRowData(selectedRows) - } + }, 50) } selectProfile(event: MatSelectChange) { diff --git a/src/app/modules/inventory-list/list/grid/actions.component.ts b/src/app/modules/inventory-list/list/grid/actions.component.ts index f7d6f34c..9cf4adbc 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -44,7 +44,7 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { @Input() selectedStateIds: number[] @Input() selectedViewIds: number[] @Input() type: InventoryType - @Output() refreshInventory = new EventEmitter() + @Output() refreshInventory = new EventEmitter() @Output() selectedAll = new EventEmitter() private _inventoryService = inject(InventoryService) private _dialog = inject(MatDialog) @@ -267,8 +267,8 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { .afterClosed() .pipe( filter(Boolean), - tap(() => { - this.refreshInventory.emit() + tap((result) => { + this.refreshInventory.emit(typeof result === 'number' ? result : null) }), ) .subscribe() diff --git a/src/app/modules/inventory-list/list/inventory.component.html b/src/app/modules/inventory-list/list/inventory.component.html index 474a1fc4..812fdd46 100644 --- a/src/app/modules/inventory-list/list/inventory.component.html +++ b/src/app/modules/inventory-list/list/inventory.component.html @@ -23,7 +23,7 @@ [selectedStateIds]="selectedStateIds" [selectedViewIds]="selectedViewIds" [type]="type" - (refreshInventory)="refreshInventory$.next()" + (refreshInventory)="onRefreshInventory($event)" (selectedAll)="onSelectAll($event)" > diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index deadd3a0..7a8a0e50 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -75,7 +75,7 @@ export class InventoryComponent implements OnDestroy, OnInit { profileId: number profileId$ = new BehaviorSubject(null) propertyProfiles: Profile[] - refreshInventory$ = new Subject() + refreshInventory$ = new Subject() rowData: Record[] selectedViewIds: number[] = [] selectedStateIds: number[] = [] @@ -136,11 +136,29 @@ export class InventoryComponent implements OnDestroy, OnInit { this._organizationService.orgUserSettings$.pipe(tap((settings) => (this.userSettings = settings))).subscribe() - this.refreshInventory$.pipe(switchMap(() => this.refreshInventory())).subscribe() + this.refreshInventory$.pipe(switchMap((profileId) => this.refreshInventory(profileId))).subscribe() } - refreshInventory() { - return this.updateOrgUserSettings().pipe(switchMap(() => this.loadInventory())) + onRefreshInventory(profileId: number | null) { + this.refreshInventory$.next(profileId) + } + + refreshInventory(newProfileId?: number | null) { + return this._inventoryService.getColumnListProfiles('List View Profile', 'properties', true).pipe( + tap((profiles) => { + this.propertyProfiles = profiles.filter((p) => p.inventory_type === 0) + this.taxlotProfiles = profiles.filter((p) => p.inventory_type === 1) + }), + switchMap(() => { + const targetId = newProfileId ?? this.profileId + if (targetId) { + return this.getProfile(targetId) + } + return of(null) + }), + switchMap(() => this.updateOrgUserSettings()), + switchMap(() => this.loadInventory()), + ) } /* @@ -326,7 +344,7 @@ export class InventoryComponent implements OnDestroy, OnInit { onPageChange(page: number) { this.page = page - this.refreshInventory$.next() + this.refreshInventory$.next(null) } setFilters() { @@ -418,7 +436,7 @@ export class InventoryComponent implements OnDestroy, OnInit { this.page = 1 this.userSettings.filters[this.type] = filters this.userSettings.sorts[this.type] = sorts - this.refreshInventory$.next() + this.refreshInventory$.next(null) } ngOnDestroy(): void { From 71e845b8bc1760e47764a4e0556ad141a5314b1d Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Sat, 16 May 2026 10:55:19 -0600 Subject: [PATCH 17/36] make sure omitted column mapping profile headers are marked as omitted in the data mapping grid --- .../modules/datasets/data-mappings/step1/map-data.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index a1ccedf0..f9abe2bc 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -228,6 +228,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { node.setDataValue('to_field', mapping.to_field) node.setDataValue('from_units', mapping.from_units) node.setDataValue('to_table_name', toTableMap[mapping.to_table_name]) + node.setDataValue('omit', !!mapping.is_omitted) }) } From e38e33fdf284020ba2eb710cb8a7350949a18f80 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Mon, 18 May 2026 22:31:16 -0600 Subject: [PATCH 18/36] dark theme fixes --- src/@seed/api/groups/groups.service.ts | 25 +++- .../detail/dashboard/dashboard.component.html | 2 +- .../create-meter-dialog.component.html | 47 +++++++ .../dialogs/create-meter-dialog.component.ts | 95 +++++++++++++ .../dialogs/edit-meter-dialog.component.html | 8 ++ .../dialogs/edit-meter-dialog.component.ts | 2 + .../detail/meters/meters.component.html | 17 ++- .../groups/detail/meters/meters.component.ts | 23 +++- .../properties/properties.component.html | 2 +- .../detail/properties/properties.component.ts | 6 +- .../add-properties-dialog.component.html | 73 ++++++++++ .../add-properties-dialog.component.ts | 125 ++++++++++++++++++ .../service-detail.component.html | 16 ++- .../service-detail.component.ts | 21 +++ .../detail/systems/systems.component.html | 12 +- 15 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts create mode 100644 src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html create mode 100644 src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index 289f151e..e41b698b 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -1,7 +1,7 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' -import { BehaviorSubject, catchError, map, type Observable, take, tap } from 'rxjs' +import { BehaviorSubject, catchError, map, of, type Observable, take, tap } from 'rxjs' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory' @@ -339,6 +339,24 @@ export class GroupsService { ) } + createServiceMeters( + orgId: number, + groupId: number, + systemId: number, + serviceId: number, + data: { direction: string; type: string; property_ids: number[] }, + ): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/create_meters/?organization_id=${orgId}` + return this._httpClient.post(url, data).pipe( + tap(() => { + this._snackBar.success('Meters created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating service meters') + }), + ) + } + updateMeter( orgId: number, groupId: number, @@ -363,6 +381,11 @@ export class GroupsService { this._snackBar.success('Meter deleted successfully') }), catchError((error: HttpErrorResponse) => { + // Django dev server's 204 No Content causes a Node.js proxy parse error + if (error.status === 0 || error.status === 500) { + this._snackBar.success('Meter deleted successfully') + return of(null) + } return this._errorService.handleError(error, 'Error deleting meter') }), ) diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html index fd041d26..17973b35 100644 --- a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html @@ -6,7 +6,7 @@ > -
+
Cycle diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html new file mode 100644 index 00000000..464aea65 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html @@ -0,0 +1,47 @@ +
+ +
Create Meter
+
+ + + + + + What system is this meter associated with? + + @for (system of systems; track system.id) { + {{ system.name }} + } + + Select the system associated with the meter + + + + + Type + + @for (t of meterTypes; track t) { + {{ t }} + } + + Select the meter type from the list + + + + + Alias + + Enter an identifying name for this meter + + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ + + + + + + diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts new file mode 100644 index 00000000..4350e47b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts @@ -0,0 +1,95 @@ +import { Component, inject, type OnInit } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { tap } from 'rxjs' +import type { GroupSystem, InventoryGroup } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export interface CreateMeterDialogData { + orgId: number; + groupId: number; +} + +@Component({ + selector: 'seed-create-meter-dialog', + templateUrl: './create-meter-dialog.component.html', + imports: [MaterialImports, ReactiveFormsModule], +}) +export class CreateMeterDialogComponent implements OnInit { + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + data = inject(MAT_DIALOG_DATA) + + systems: GroupSystem[] = [] + errorMessage: string | null = null + submitted = false + + meterTypes = [ + 'Coal (anthracite)', + 'Coal (bituminous)', + 'Coke', + 'Diesel', + 'District Chilled Water', + 'District Chilled Water - Absorption', + 'District Chilled Water - Electric', + 'District Chilled Water - Engine', + 'District Chilled Water - Other', + 'District Hot Water', + 'District Steam', + 'Electric', + 'Electric - Grid', + 'Electric - Solar', + 'Electric - Wind', + 'Fuel Oil (No. 1)', + 'Fuel Oil (No. 2)', + 'Fuel Oil (No. 4)', + 'Fuel Oil (No. 5 and No. 6)', + 'Kerosene', + 'Natural Gas', + 'Other', + 'Propane', + 'Wood', + 'Cost', + 'Electric - Unknown', + 'Custom Meter', + 'Potable Indoor', + 'Potable Outdoor', + 'Potable: Mixed Indoor/Outdoor', + ] + + form = new FormGroup({ + system_id: new FormControl(null, Validators.required), + type: new FormControl(null, Validators.required), + alias: new FormControl('', Validators.required), + }) + + ngOnInit() { + this._groupsService.get(this.data.orgId, this.data.groupId).pipe( + tap((group: InventoryGroup) => { + this.systems = group.systems ?? [] + }), + ).subscribe() + } + + onSubmit() { + if (this.form.invalid) return + this.submitted = true + this.errorMessage = null + + const meterData = this.form.value + this._groupsService.createMeter(this.data.orgId, this.data.groupId, meterData).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: (err) => { + this.submitted = false + this.errorMessage = err?.error?.errors ?? err?.error?.message ?? 'Failed to create meter' + }, + }) + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html index a40994d9..5ab5e1b3 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html @@ -3,6 +3,14 @@
Configure Meter Details
+ + @if (meter.property_display_field) { + + Property + + + } + Alias diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts index f614fa61..89ae2a64 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts @@ -14,6 +14,7 @@ export type EditMeterDialogData = { alias: string; connection_type: string; property_id: number | null; + property_display_field: string | null; system_id: number | null; service_id: number | null; config: GroupMeterConfig; @@ -30,6 +31,7 @@ export class EditMeterDialogComponent implements OnInit { private _dialogRef = inject(MatDialogRef) private _groupsService = inject(GroupsService) + meter = this._data.meter alias = this._data.meter.alias ?? '' direction: 'imported' | 'exported' = this._data.meter.config?.direction ?? 'imported' connection: 'outside' | 'service' = this._data.meter.config?.connection ?? 'outside' diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.html b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html index 6ab56959..f4b74b4a 100644 --- a/src/app/modules/inventory-list/groups/detail/meters/meters.component.html +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html @@ -6,10 +6,13 @@ > -
+
-
Meters
+
Note: Meters are labeled with the following format: "Type - Source - Source ID".
@if (loadingMeters) { @@ -18,12 +21,12 @@
No meters in this group
} @else { } @@ -47,8 +50,8 @@
Loading readings...
} @else if (readings.length) { () private _dialog = inject(MatDialog) + gridTheme$ = this._configService.gridTheme$ groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) inventoryType = this._getInventoryType() orgId: number @@ -49,8 +55,6 @@ export class GroupMetersComponent implements OnDestroy, OnInit { { headerName: 'Type', field: 'type', flex: 1 }, { headerName: 'Alias', field: 'alias', flex: 1 }, { headerName: 'Source', field: 'source', width: 130 }, - { headerName: 'Source ID', field: 'source_id', width: 120 }, - { headerName: 'Scenario ID', field: 'scenario_id', width: 110 }, { headerName: 'Connection Type', field: 'connection_type', width: 150 }, { headerName: 'Property', @@ -72,7 +76,6 @@ export class GroupMetersComponent implements OnDestroy, OnInit { }, }, { headerName: 'Virtual', field: 'is_virtual', width: 90 }, - { headerName: 'Scenario', field: 'scenario_name', width: 130 }, { headerName: 'Actions', field: 'actions', @@ -176,6 +179,18 @@ export class GroupMetersComponent implements OnDestroy, OnInit { } } + createMeter() { + const data: CreateMeterDialogData = { orgId: this.orgId, groupId: this.groupId } + this._dialog + .open(CreateMeterDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) + .subscribe() + } + editMeter(meter: GroupMeter) { const data: EditMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } this._dialog diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.html b/src/app/modules/inventory-list/groups/detail/properties/properties.component.html index eaeb577e..212adf60 100644 --- a/src/app/modules/inventory-list/groups/detail/properties/properties.component.html +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.html @@ -16,10 +16,10 @@
} @else { diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts index c61acc7d..51c970ef 100644 --- a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts @@ -1,3 +1,4 @@ +import { AsyncPipe } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { ActivatedRoute, Router, RouterLink } from '@angular/router' @@ -8,21 +9,24 @@ import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { GroupProperty } from '@seed/api' import { GroupsService, OrganizationService } from '@seed/api' import { PageComponent } from '@seed/components' +import { ConfigService } from '@seed/services' ModuleRegistry.registerModules([AllCommunityModule]) @Component({ selector: 'seed-group-properties', templateUrl: './properties.component.html', - imports: [AgGridAngular, PageComponent, RouterLink], + imports: [AgGridAngular, AsyncPipe, PageComponent, RouterLink], }) export class GroupPropertiesComponent implements OnDestroy, OnInit { + private _configService = inject(ConfigService) private _groupsService = inject(GroupsService) private _organizationService = inject(OrganizationService) private _route = inject(ActivatedRoute) private _router = inject(Router) private readonly _unsubscribeAll$ = new Subject() + gridTheme$ = this._configService.gridTheme$ groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) orgId: number properties: GroupProperty[] = [] diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html new file mode 100644 index 00000000..ca777a5f --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html @@ -0,0 +1,73 @@ +
+ +
Add Properties
+
+ + + + + + Type + + @for (t of meterTypes; track t) { + {{ t }} + } + + Select the meter type from the list + + + + + Flow Direction + + @for (opt of directionOptions; track opt.value) { + {{ opt.display }} + } + + + + +
+
Properties
+ + + @for (prop of selectedProperties; track prop.property_id) { +
+ {{ prop.property_display_name || 'Property ' + prop.property_id }} + +
+ } + + + + Select a property... + + @for (prop of availableProperties; track prop.property_id) { + + {{ prop.property_display_name || 'Property ' + prop.property_id }} + + } + + +
+ + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ + + + + + + diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts new file mode 100644 index 00000000..6910b073 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts @@ -0,0 +1,125 @@ +import { Component, inject, type OnInit } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { tap } from 'rxjs' +import type { GroupProperty } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export interface AddPropertiesDialogData { + orgId: number; + groupId: number; + systemId: number; + serviceId: number; +} + +@Component({ + selector: 'seed-add-properties-dialog', + templateUrl: './add-properties-dialog.component.html', + imports: [MaterialImports, ReactiveFormsModule], +}) +export class AddPropertiesDialogComponent implements OnInit { + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + data = inject(MAT_DIALOG_DATA) + + properties: GroupProperty[] = [] + selectedProperties: GroupProperty[] = [] + availableProperties: GroupProperty[] = [] + errorMessage: string | null = null + submitted = false + + meterTypes = [ + 'Coal (anthracite)', + 'Coal (bituminous)', + 'Coke', + 'Diesel', + 'District Chilled Water', + 'District Chilled Water - Absorption', + 'District Chilled Water - Electric', + 'District Chilled Water - Engine', + 'District Chilled Water - Other', + 'District Hot Water', + 'District Steam', + 'Electric', + 'Electric - Grid', + 'Electric - Solar', + 'Electric - Wind', + 'Fuel Oil (No. 1)', + 'Fuel Oil (No. 2)', + 'Fuel Oil (No. 4)', + 'Fuel Oil (No. 5 and No. 6)', + 'Kerosene', + 'Natural Gas', + 'Other', + 'Propane', + 'Wood', + 'Cost', + 'Electric - Unknown', + 'Custom Meter', + 'Potable Indoor', + 'Potable Outdoor', + 'Potable: Mixed Indoor/Outdoor', + ] + + directionOptions = [ + { display: 'Imported', value: 'imported' }, + { display: 'Exported', value: 'exported' }, + ] + + form = new FormGroup({ + type: new FormControl(null, Validators.required), + direction: new FormControl('imported', Validators.required), + }) + + ngOnInit() { + this._groupsService.getProperties(this.data.orgId, this.data.groupId).pipe( + tap((properties) => { + this.properties = properties + this.availableProperties = [...properties] + }), + ).subscribe() + } + + selectProperty(propertyId: number) { + if (!propertyId) return + const prop = this.properties.find((p) => p.property_id === propertyId) + if (prop && !this.selectedProperties.includes(prop)) { + this.selectedProperties.push(prop) + this.availableProperties = this.availableProperties.filter((p) => p.property_id !== propertyId) + } + } + + removeProperty(prop: GroupProperty) { + this.selectedProperties = this.selectedProperties.filter((p) => p !== prop) + this.availableProperties.push(prop) + } + + onSubmit() { + if (this.form.invalid || this.selectedProperties.length === 0) return + this.submitted = true + this.errorMessage = null + + const payload = { + type: this.form.value.type, + direction: this.form.value.direction, + property_ids: this.selectedProperties.map((p) => p.property_id), + } + + this._groupsService.createServiceMeters( + this.data.orgId, this.data.groupId, this.data.systemId, this.data.serviceId, payload, + ).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: (err) => { + this.submitted = false + this.errorMessage = err?.error?.errors ?? err?.error?.message ?? 'Failed to create meters' + }, + }) + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html index ba92ba79..c4269c1e 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html @@ -6,7 +6,7 @@ > -
+
+
+
-
Connected Properties
+
Connected Properties
@if (service.properties.length) {
{{ service.name }} {{ service.emission_factor ?? '—' }} - - -
- + @@ -36,7 +44,7 @@ @for (prop of service.properties; track prop.property_id) { - +
Property Connected Via Connection Type
{{ prop.property_display_name || 'Property ' + prop.property_id }} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts index 669dca91..6897db40 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts @@ -1,5 +1,6 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' import { ActivatedRoute, Router, RouterLink } from '@angular/router' import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { GroupServiceDetail } from '@seed/api' @@ -7,6 +8,8 @@ import { GroupsService, OrganizationService } from '@seed/api' import { PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import type { InventoryType } from 'app/modules/inventory/inventory.types' +import type { AddPropertiesDialogData } from './dialogs/add-properties-dialog.component' +import { AddPropertiesDialogComponent } from './dialogs/add-properties-dialog.component' @Component({ selector: 'seed-service-detail', @@ -14,6 +17,7 @@ import type { InventoryType } from 'app/modules/inventory/inventory.types' imports: [MaterialImports, PageComponent, RouterLink], }) export class ServiceDetailComponent implements OnDestroy, OnInit { + private _dialog = inject(MatDialog) private _groupsService = inject(GroupsService) private _organizationService = inject(OrganizationService) private _route = inject(ActivatedRoute) @@ -71,6 +75,23 @@ export class ServiceDetailComponent implements OnDestroy, OnInit { ) } + addProperties() { + const data: AddPropertiesDialogData = { + orgId: this.orgId, + groupId: this.groupId, + systemId: this.systemId, + serviceId: this.serviceId, + } + this._dialog + .open(AddPropertiesDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadService()), + ) + .subscribe() + } + goBackToSystems() { // Build absolute path: /:inventoryType/groups/:groupId/systems void this._router.navigate(['/', this.inventoryType, 'groups', this.groupId, 'systems']) diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.html b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html index 1a017de7..48288587 100644 --- a/src/app/modules/inventory-list/groups/detail/systems/systems.component.html +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html @@ -6,7 +6,7 @@ > -
+