From ec172a25147a0c0b8c9e7dff9b1f4e33c6276ea3 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 21 Jul 2025 20:35:20 +0000 Subject: [PATCH 01/30] action group modal --- src/@seed/api/groups/groups.service.ts | 16 ++ .../api/organization/organization.service.ts | 12 ++ .../api/organization/organization.types.ts | 5 + src/@seed/components/index.ts | 1 + src/@seed/components/menu/index.ts | 1 + .../components/menu/menu-item.component.html | 14 ++ .../components/menu/menu-item.component.ts | 20 ++ .../list/actions/groups-modal.component.html | 59 +++++ .../list/actions/groups-modal.component.ts | 153 +++++++++++++ .../inventory-list/list/actions/index.ts | 1 + .../list/grid/actions.component.html | 204 ++++++++++++++++-- .../list/grid/actions.component.ts | 97 ++++----- .../list/grid/cell-header-menu.component.html | 2 +- src/styles/styles.scss | 10 + 14 files changed, 522 insertions(+), 73 deletions(-) create mode 100644 src/@seed/components/menu/index.ts create mode 100644 src/@seed/components/menu/menu-item.component.html create mode 100644 src/@seed/components/menu/menu-item.component.ts create mode 100644 src/app/modules/inventory-list/list/actions/groups-modal.component.html create mode 100644 src/app/modules/inventory-list/list/actions/groups-modal.component.ts create mode 100644 src/app/modules/inventory-list/list/actions/index.ts diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index 1cdd80be..f4ec9697 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -92,4 +92,20 @@ export class GroupsService { }), ) } + + bulkUpdate(orgId: number, addGroupIds: number[], removeGroupIds: number[], viewIds: number[], type: 'property' | 'tax_lot'): Observable { + const url = `/api/v3/inventory_group_mappings/put/?organization_id=${orgId}` + const data = { + inventory_ids: viewIds, + add_group_ids: addGroupIds, + remove_group_ids: removeGroupIds, + inventory_type: type, + } + return this._httpClient.put(url, data).pipe( + tap(() => { this.list(orgId) }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating groups') + }), + ) + } } diff --git a/src/@seed/api/organization/organization.service.ts b/src/@seed/api/organization/organization.service.ts index 9fb147a1..11ec334f 100644 --- a/src/@seed/api/organization/organization.service.ts +++ b/src/@seed/api/organization/organization.service.ts @@ -20,6 +20,7 @@ import type { CreateAccessLevelInstanceRequest, EditAccessLevelInstanceRequest, EditAccessLevelInstanceResponse, + FilterByViewsResponse, MatchingCriteriaColumnsResponse, Organization, OrganizationResponse, @@ -184,6 +185,17 @@ export class OrganizationService { ) } + filterAccessLevelsByViews(orgId: number, type: InventoryType, viewIds: number[]): Observable { + const url = `/api/v3/organizations/${orgId}/access_levels/filter_by_views/` + const data = { inventory_type: type, view_ids: viewIds } + return this._httpClient.post(url, data).pipe( + map(({ access_level_instance_ids }) => access_level_instance_ids), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error filtering access levels by views') + }), + ) + } + canDeleteAccessLevelInstance(organizationId: number, accessLevelInstanceId: number) { const url = `/api/v3/organizations/${organizationId}/access_levels/${accessLevelInstanceId}/can_delete_instance/` return this._httpClient.get(url).pipe( diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index beb125d4..9de0b73f 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -203,3 +203,8 @@ export type MatchingCriteriaColumnsResponse = { PropertyState: string[]; TaxLotState: string[]; } + +export type FilterByViewsResponse = { + access_level_instance_ids: number[]; + status: string; +} diff --git a/src/@seed/components/index.ts b/src/@seed/components/index.ts index 394ce3d0..8161d234 100644 --- a/src/@seed/components/index.ts +++ b/src/@seed/components/index.ts @@ -8,6 +8,7 @@ export * from './ag-grid' export * from './label' export * from './loading-bar' export * from './masonry' +export * from './menu' export * from './modal' export * from './navigation' export * from './not-found' diff --git a/src/@seed/components/menu/index.ts b/src/@seed/components/menu/index.ts new file mode 100644 index 00000000..b01e92af --- /dev/null +++ b/src/@seed/components/menu/index.ts @@ -0,0 +1 @@ +export * from './menu-item.component' diff --git a/src/@seed/components/menu/menu-item.component.html b/src/@seed/components/menu/menu-item.component.html new file mode 100644 index 00000000..8a7ce3aa --- /dev/null +++ b/src/@seed/components/menu/menu-item.component.html @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/src/@seed/components/menu/menu-item.component.ts b/src/@seed/components/menu/menu-item.component.ts new file mode 100644 index 00000000..4e9a924c --- /dev/null +++ b/src/@seed/components/menu/menu-item.component.ts @@ -0,0 +1,20 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { MaterialImports } from '@seed/materials' + +@Component({ + selector: 'seed-menu-item', + templateUrl: './menu-item.component.html', + imports: [MaterialImports], +}) +export class MenuItemComponent { + @Input() label = '' + @Input() icon?: string + @Input() disabled = false + @Output() action = new EventEmitter() + + onClick() { + if (!this.disabled) { + this.action.emit() + } + } +} diff --git a/src/app/modules/inventory-list/list/actions/groups-modal.component.html b/src/app/modules/inventory-list/list/actions/groups-modal.component.html new file mode 100644 index 00000000..60459c78 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/groups-modal.component.html @@ -0,0 +1,59 @@ + +@if (loading) { +
+ +
+} +@else if (allSameAli) { + + + + + +
+ + Not seeing the right group? Create a new one. +
+ + Group Name + + @if (form.controls.name?.hasError('valueExists')) { + This name already exists. + } + + + +
+
+ +} @else { + + + Selection includes multiple Access Level Instances. To update or create a group, all properties must be in the same access level instance. Modify selection and try again. + +} + +
+ + +
\ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/groups-modal.component.ts b/src/app/modules/inventory-list/list/actions/groups-modal.component.ts new file mode 100644 index 00000000..f0b8fe55 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/groups-modal.component.ts @@ -0,0 +1,153 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import type { CurrentUser, InventoryGroup } from '@seed/api' +import { GroupsService, OrganizationService, UserService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import { SEEDValidators } from '@seed/validators' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import type { InventoryDisplayType, InventoryType } from 'app/modules/inventory/inventory.types' +import { Subject, switchMap, take, takeUntil, tap } from 'rxjs' + +@Component({ + selector: 'seed-inventory-groups-modal', + templateUrl: './groups-modal.component.html', + imports: [AgGridAngular, + AlertComponent, + CommonModule, + FormsModule, + MaterialImports, + ModalHeaderComponent, + ReactiveFormsModule, + ], +}) +export class GroupsModalComponent implements OnDestroy, OnInit { + private _dialogRef = inject(MatDialogRef) + private _configService = inject(ConfigService) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _userService = inject(UserService) + private readonly _unsubscribeAll$ = new Subject() + + aliIds: number[] = [] + aliId: number + // aliIds: number[] = [] + currentUser: CurrentUser + gridTheme$ = this._configService.gridTheme$ + groups: InventoryGroup[] = [] + aliGroups: (InventoryGroup & { add: boolean; remove: boolean })[] = [] + gridApi: GridApi + columnDefs: ColDef[] = [] + allSameAli = true + loading = true + + data = inject(MAT_DIALOG_DATA) as { orgId: number; type: InventoryType; viewIds: number[]; existingGroupNames: string[] } + + form = new FormGroup({ + name: new FormControl('', [ + Validators.required, + SEEDValidators.uniqueValue(this.data.existingGroupNames), + ]), + organization: new FormControl(this.data.orgId), + inventory_type: new FormControl(this.data.type === 'taxlots' ? 'Tax Lot' : 'Property'), + access_level_instance: new FormControl(null, Validators.required), + }) + + ngOnInit(): void { + const { orgId, type, viewIds } = this.data + this._groupsService.list(orgId) + this._organizationService.filterAccessLevelsByViews(orgId, type, viewIds) + .pipe( + tap((aliIds) => { + this.aliIds = aliIds + this.setGrid() + }), + switchMap(() => this._groupsService.groups$), + tap((groups) => { this.setGroups(groups) }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + setGroups(groups: InventoryGroup[]) { + this.groups = groups + this.aliGroups = this.groups + .filter((g) => this.aliIds.includes(g.access_level_instance)) + .map((group) => ({ ...group, add: false, remove: false })) + this.aliId = this.aliGroups[0]?.access_level_instance + this.allSameAli = this.aliGroups.every((g) => g.access_level_instance === this.aliId) + if (this.allSameAli) { + this.form.patchValue({ access_level_instance: this.aliId }) + } + this.loading = false + } + + setGrid() { + this.setRowData() + this.columnDefs = [ + { field: 'name', headerName: 'Group Name' }, + { field: 'access_level_instance_data.name', headerName: 'Access Level Instance' }, + { field: 'inventory_list', headerName: 'Inventory', flex: 0.5, valueFormatter: ({ data }: { data: InventoryGroup }) => String(data.inventory_list.length) }, + { field: 'add', headerName: 'Add', flex: 0.5, editable: this.allSameAli, headerClass: () => this.allSameAli ? '' : 'text-secondary' }, + { field: 'remove', headerName: 'Remove', flex: 0.5, editable: true }, + ] + } + + setRowData() { + this.aliGroups = this.groups + .filter((g) => this.aliIds.includes(g.access_level_instance)) + .map((group) => ({ ...group, add: false, remove: false })) + } + + onCellValueChanged(event: CellValueChangedEvent): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { colDef, newValue, node } = event + const field = colDef.field + const otherField = field === 'add' ? 'remove' : 'add' + const data = node.data as InventoryGroup & { add: boolean; remove: boolean } + + if (newValue && data[otherField]) { + node.setDataValue(otherField, false) + } + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + this.gridApi.autoSizeAllColumns() + } + + onSubmit() { + this._groupsService.create(this.data.orgId, this.form.value as unknown as InventoryGroup) + .pipe(take(1)) + .subscribe() + } + + done() { + const { orgId, viewIds, type } = this.data + const groupType = type === 'taxlots' ? 'tax_lot' : 'property' + const addGroupIds: number[] = this.aliGroups.filter((g) => g.add).map((g) => g.id) + const removeGroupIds: number[] = this.aliGroups.filter((g) => g.remove).map((g) => g.id) + + if (!addGroupIds.length && !removeGroupIds.length) { + this.close() + return + } + + this._groupsService.bulkUpdate(orgId, addGroupIds, removeGroupIds, viewIds, groupType).subscribe(() => { + this.close(true) + }) + } + + close(success = false) { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts new file mode 100644 index 00000000..14cbe50e --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -0,0 +1 @@ +export * from './groups-modal.component' diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 3fe8388d..d853c8ec 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -1,14 +1,192 @@ -
- Actions - - @for (item of actions; track $index) { - {{ item.name }} - } - + +
+ Actions +
+
Select Action
+ +
+ + + + + + + + + + + Access Levels +
+ + + + +
+ + + Analyses +
+ + +
+ + + Audit Template +
+ + + + +
+ + + + + Data Quality +
+ + +
+ + + Derived Data +
+ + +
+ + + + + + + + + + Groups +
+ + +
+ + + Labels +
+ + +
+ + + + + + + Salesforce +
+ + +
+ + + UBID +
+ + + + + + +
+ +
\ No newline at end of file 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 3faaa376..4f22b892 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -1,22 +1,21 @@ -import type { OnDestroy } from '@angular/core' +import type { OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core' import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatDialog } from '@angular/material/dialog' -import { type MatSelect } from '@angular/material/select' import type { GridApi } from 'ag-grid-community' import { filter, Subject, switchMap, takeUntil, tap } from 'rxjs' -import { InventoryService } from '@seed/api' -import { DeleteModalComponent } from '@seed/components' +import { GroupsService, InventoryService } from '@seed/api' +import { DeleteModalComponent, MenuItemComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.component' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { MoreActionsModalComponent } from '../modal' +import { GroupsModalComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', templateUrl: './actions.component.html', - imports: [DeleteModalComponent, MaterialImports], + imports: [MenuItemComponent, DeleteModalComponent, MaterialImports], }) -export class ActionsComponent implements OnDestroy { +export class ActionsComponent implements OnDestroy, OnChanges, OnInit { @Input() cycleId: number @Input() gridApi: GridApi @Input() inventory: Record[] @@ -28,65 +27,28 @@ export class ActionsComponent implements OnDestroy { @Output() refreshInventory = new EventEmitter() @Output() selectedAll = new EventEmitter() private _inventoryService = inject(InventoryService) + private _groupsService = inject(GroupsService) private _dialog = inject(MatDialog) private readonly _unsubscribeAll$ = new Subject() + hasSelection: boolean + existingGroupNames: string[] = [] - get actions() { - return [ - { - name: 'Select All', - action: () => { - this.selectAll() - }, - disabled: false, - }, - { - name: 'Select None', - action: () => { - this.deselectAll() - }, - disabled: false, - }, - { - name: 'Only Show Populated Columns', - action: () => { - this.openShowPopulatedColumnsModal() - }, - disabled: !this.inventory, - }, - { name: 'Delete', action: this.deleteStates, disabled: !this.selectedViewIds.length }, - { name: 'Merge', action: this.tempAction, disabled: !this.selectedViewIds.length }, - { - name: 'More...', - action: () => { - this.openMoreActionsModal() - }, - disabled: !this.selectedViewIds.length, - }, - ] + ngOnInit(): void { + this._groupsService.list(this.orgId) + this._groupsService.groups$.pipe( + takeUntil(this._unsubscribeAll$), + tap((groups) => { this.existingGroupNames = groups.map((g) => g.name) }), + ).subscribe() } - tempAction() { - console.log('temp action') - } - - openMoreActionsModal() { - const dialogRef = this._dialog.open(MoreActionsModalComponent, { - width: '40rem', - autoFocus: false, - data: { viewIds: this.selectedViewIds, orgId: this.orgId, type: this.type }, - }) - - dialogRef.afterClosed() - .pipe( - filter(Boolean), - tap(() => { this.refreshInventory.emit() }), - ).subscribe() + ngOnChanges(changes: SimpleChanges): void { + if (changes.selectedViewIds) { + this.hasSelection = this.selectedViewIds.length > 0 + } } - onAction(action: () => void, select: MatSelect) { - action() - select.value = null + tempAction() { + console.log('temp action') } selectAll() { @@ -157,6 +119,23 @@ export class ActionsComponent implements OnDestroy { }) } + openGroupsModal() { + const dialogRef = this._dialog.open(GroupsModalComponent, { + width: '50rem', + data: { + orgId: this.orgId, + type: this.type, + viewIds: this.selectedViewIds, + existingGroupNames: this.existingGroupNames, + }, + }) + + dialogRef.afterClosed().pipe( + filter(Boolean), + tap(() => { this.refreshInventory.emit() }), + ).subscribe() + } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/app/modules/inventory-list/list/grid/cell-header-menu.component.html b/src/app/modules/inventory-list/list/grid/cell-header-menu.component.html index 3000a815..f97e98ea 100644 --- a/src/app/modules/inventory-list/list/grid/cell-header-menu.component.html +++ b/src/app/modules/inventory-list/list/grid/cell-header-menu.component.html @@ -13,7 +13,7 @@ -
+
@@ -98,7 +98,30 @@
- + + + Groups +
+ + +
+ + + Labels +
+ + +
+ + + + Other - - - - Groups -
- - -
- - - Labels -
- - -
- - - Salesforce 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 e5a22c39..6632682d 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -39,6 +39,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { return } + baseData() { + return { + orgId: this.orgId, + type: this.type, + viewIds: this.selectedViewIds, + } + } + ngOnChanges(changes: SimpleChanges): void { if (changes.selectedViewIds) { this.hasSelection = this.selectedViewIds.length > 0 @@ -53,7 +61,7 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.gridApi.selectAll() const inventory_type = this.type === 'taxlots' ? 'taxlot' : 'property' const params = new URLSearchParams({ - cycle: this.cycleId.toString(), + cycle: this.cycleId?.toString(), ids_only: 'true', include_related: 'true', organization_id: this.orgId.toString(), @@ -116,36 +124,24 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { openAliChangeModal() { const dialogRef = this._dialog.open(AliChangeModalComponent, { width: '40rem', - data: { - orgId: this.orgId, - viewIds: this.selectedViewIds, - }, + data: this.baseData(), }) - this.afterClosed(dialogRef) } openAnalysisRunModal() { const dialogRef = this._dialog.open(AnalysisRunModalComponent, { width: '40rem', - data: { - orgId: this.orgId, - viewIds: this.selectedViewIds, - }, + data: this.baseData(), }) - this.afterClosed(dialogRef) } - startDataQualityCheck() { + openDataQualityCheck() { const dialogRef = this._dialog.open(DQCStartModalComponent, { width: '40rem', disableClose: true, - data: { - orgId: this.orgId, - type: this.type, - viewIds: this.selectedViewIds, - }, + data: this.baseData(), }) this.afterClosed(dialogRef) } @@ -153,11 +149,7 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { openDerivedDataUpdateModal() { const dialogRef = this._dialog.open(UpdateDerivedDataComponent, { width: '40rem', - data: { - orgId: this.orgId, - viewIds: this.selectedViewIds, - type: this.type, - }, + data: this.baseData(), }) this.afterClosed(dialogRef) } @@ -165,13 +157,16 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { openGroupsModal() { const dialogRef = this._dialog.open(GroupsModalComponent, { width: '50rem', - data: { - orgId: this.orgId, - type: this.type, - viewIds: this.selectedViewIds, - }, + data: this.baseData(), }) + this.afterClosed(dialogRef) + } + openLabelsModal() { + const dialogRef = this._dialog.open(GroupsModalComponent, { + width: '50rem', + data: this.baseData(), + }) this.afterClosed(dialogRef) } diff --git a/src/app/modules/inventory/actions/analysis-config/simple-config.component.ts b/src/app/modules/inventory/actions/analysis-config/simple-config.component.ts index 92c87f9e..1a63fa62 100644 --- a/src/app/modules/inventory/actions/analysis-config/simple-config.component.ts +++ b/src/app/modules/inventory/actions/analysis-config/simple-config.component.ts @@ -56,7 +56,7 @@ export class SimpleConfigComponent implements OnChanges, OnDestroy, OnInit { 'Element Statistics': this.formES, } this.form = this.formMap[this.service] - this.watchForm() + this.formChange.emit(this.form) } ngOnChanges(changes: SimpleChanges): void { diff --git a/src/app/modules/inventory/actions/index.ts b/src/app/modules/inventory/actions/index.ts index 8318b702..98a2b7fc 100644 --- a/src/app/modules/inventory/actions/index.ts +++ b/src/app/modules/inventory/actions/index.ts @@ -2,3 +2,4 @@ export * from './ali-change-modal.component' export * from './analysis-config' export * from './analysis-run-modal.component' export * from './groups-modal.component' +export * from './labels-modal.component' diff --git a/src/app/modules/inventory/actions/labels-modal.component.html b/src/app/modules/inventory/actions/labels-modal.component.html new file mode 100644 index 00000000..26915a78 --- /dev/null +++ b/src/app/modules/inventory/actions/labels-modal.component.html @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/src/app/modules/inventory/actions/labels-modal.component.ts b/src/app/modules/inventory/actions/labels-modal.component.ts new file mode 100644 index 00000000..6c0c8c45 --- /dev/null +++ b/src/app/modules/inventory/actions/labels-modal.component.ts @@ -0,0 +1,74 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit} from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import type { Label} from '@seed/api' +import { LabelService } from '@seed/api' +import { ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { AgGridAngular } from 'ag-grid-angular' +import { Subject, takeUntil, tap } from 'rxjs' +import { InventoryType } from '../inventory.types' +import { CellValueChangedEvent, ColDef } from 'ag-grid-community' +import { ConfigService } from '@seed/services' + +@Component({ + selector: 'seed-labels-modal', + templateUrl: './labels-modal.component.html', + imports: [ + AgGridAngular, + CommonModule, + FormsModule, + MaterialImports, + ModalHeaderComponent, + ReactiveFormsModule, + ], +}) +export class LabelsModalComponent implements OnInit, OnDestroy { + private _unsubscribeAll$ = new Subject() + private _dialogRef = inject(MatDialogRef) + private _configService = inject(ConfigService) + private _labelService = inject(LabelService) + columnDefs: ColDef[] + labels: Label[] = [] + gridTheme$ = this._configService.gridTheme$ + + data = inject(MAT_DIALOG_DATA) as { orgId: number; type: InventoryType; viewIds: number[] } + + ngOnInit(): void { + this._labelService.labels$ + .pipe( + tap((labels) => { + this.labels = labels + this.setGrid() + }), + takeUntil(this._unsubscribeAll$), + ) + .subscribe() + } + + setGrid() { + this.columnDefs = [ + { field: 'name', headerName: 'Label', flex: 1 }, + { field: 'add', headerName: 'Add', flex: 0.5, editable: true }, + { field: 'remove', headerName: 'Remove', flex: 0.5, editable: true }, + ] + } + + onCellValueChanged(event: CellValueChangedEvent): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { colDef, newValue, node } = event + const field = colDef.field + const otherField = field === 'add' ? 'remove' : 'add' + const data = node.data as Label & { add: boolean; remove: boolean } + + if (newValue && data[otherField]) { + node.setDataValue(otherField, false) + } + } + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} \ No newline at end of file From 0507f040fe81ecef718066a01a01abb95f0e76f4 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 25 Jul 2025 19:41:55 +0000 Subject: [PATCH 11/30] label action --- src/@seed/api/label/label.service.ts | 24 +- src/@seed/api/label/label.types.ts | 4 +- .../components/menu/menu-item.component.html | 6 +- .../data-quality/results-modal.component.html | 2 +- .../data-quality/start-modal.component.html | 2 +- .../step1/map-data.component.html | 2 +- .../update-derived-data.component.html | 28 +- .../list/grid/actions.component.html | 280 +++++++----------- .../list/grid/actions.component.ts | 4 +- .../actions/ali-change-modal.component.html | 15 +- .../better-config.component.html | 50 ++-- .../analysis-config/bur-config.component.html | 51 ++-- .../simple-config.component.html | 75 +++-- .../actions/analysis-run-modal.component.html | 49 ++- .../actions/groups-modal.component.html | 39 +-- .../actions/labels-modal.component.html | 49 ++- .../actions/labels-modal.component.ts | 100 ++++++- .../labels/modal/form-modal.component.html | 3 + src/styles/styles.scss | 14 +- 19 files changed, 401 insertions(+), 396 deletions(-) diff --git a/src/@seed/api/label/label.service.ts b/src/@seed/api/label/label.service.ts index 24237e3d..b0bdfc28 100644 --- a/src/@seed/api/label/label.service.ts +++ b/src/@seed/api/label/label.service.ts @@ -2,11 +2,11 @@ import type { HttpErrorResponse, HttpResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { catchError, map, ReplaySubject, switchMap } from 'rxjs' +import { catchError, map, ReplaySubject, switchMap, tap } from 'rxjs' import { ErrorService } from '@seed/services' import { naturalSort } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { InventoryType } from 'app/modules/inventory/inventory.types' +import type { InventoryType, InventoryTypeSingular } from 'app/modules/inventory/inventory.types' import { UserService } from '../user' import type { Label } from './label.types' @@ -81,10 +81,7 @@ export class LabelService { create(label: Label): Observable
\ No newline at end of file +
diff --git a/src/app/modules/data-quality/results-modal.component.html b/src/app/modules/data-quality/results-modal.component.html index 4fa659a4..8cf29cd4 100644 --- a/src/app/modules/data-quality/results-modal.component.html +++ b/src/app/modules/data-quality/results-modal.component.html @@ -13,7 +13,7 @@ [pagination]="true" [paginationPageSize]="10" [paginationPageSizeSelector]="[10, 50, 100]" - > + > } @else {
No warnings or errors
diff --git a/src/app/modules/data-quality/start-modal.component.html b/src/app/modules/data-quality/start-modal.component.html index 7bbdfe39..b464fc14 100644 --- a/src/app/modules/data-quality/start-modal.component.html +++ b/src/app/modules/data-quality/start-modal.component.html @@ -2,4 +2,4 @@ [total]="progressBarObj.total" [progress]="progressBarObj.progress" [title]="progressBarObj.statusMessage || 'Running Data Quality Check...'" -> \ No newline at end of file +> diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.html b/src/app/modules/datasets/data-mappings/step1/map-data.component.html index 0ce7d96e..e8806a30 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.html +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.html @@ -34,10 +34,10 @@
Properties diff --git a/src/app/modules/inventory-list/list/actions/update-derived-data.component.html b/src/app/modules/inventory-list/list/actions/update-derived-data.component.html index 700eb792..031b6267 100644 --- a/src/app/modules/inventory-list/list/actions/update-derived-data.component.html +++ b/src/app/modules/inventory-list/list/actions/update-derived-data.component.html @@ -1,17 +1,14 @@ - + - +
- This process recalculates the data stored in derived columns for the - selected properties and tax lots. This may take several minutes. - + This process recalculates the data stored in derived columns for the selected properties and tax lots. This may take several + minutes. +
- + - - Access Levels -
- @if (type === 'properties') { - - - } - - -
- + + + + + + + + + + Access Levels +
@if (type === 'properties') { - - Analyses -
- - -
+ } - + +
+ + @if (type === 'properties') { - Audit Template + Analyses
- - - - -
- - - - - Data Quality -
- - -
- - - Derived Data -
- - -
- - - - Groups -
- - -
- - - Labels -
- - -
- - - - Other - - - - - - - - Salesforce -
- - -
- - - UBID -
- - - - - - +
+ } + + + Audit Template +
+ + +
+ -
\ No newline at end of file + + + Data Quality +
+ +
+ + + Derived Data +
+ +
+ + + + Groups +
+ +
+ + + Labels +
+ +
+ + + + Other + + + + + + + + Salesforce +
+ +
+ + + UBID +
+ + + + + +
+ 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 6632682d..4b7c0716 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -9,7 +9,7 @@ import { DeleteModalComponent, MenuItemComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.component' import { DQCStartModalComponent } from 'app/modules/data-quality' -import { AliChangeModalComponent, AnalysisRunModalComponent, GroupsModalComponent } from 'app/modules/inventory/actions' +import { AliChangeModalComponent, AnalysisRunModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' import { UpdateDerivedDataComponent } from '../actions' @@ -163,7 +163,7 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { } openLabelsModal() { - const dialogRef = this._dialog.open(GroupsModalComponent, { + const dialogRef = this._dialog.open(LabelsModalComponent, { width: '50rem', data: this.baseData(), }) diff --git a/src/app/modules/inventory/actions/ali-change-modal.component.html b/src/app/modules/inventory/actions/ali-change-modal.component.html index f54f82da..13b228bc 100644 --- a/src/app/modules/inventory/actions/ali-change-modal.component.html +++ b/src/app/modules/inventory/actions/ali-change-modal.component.html @@ -1,10 +1,8 @@ - +
- Please select an Access Level and Access Level Instance to move {{ data.viewIds.length }} {{ data.viewIds.length === 1 ? 'property' : 'properties'}} to. + Please select an Access Level and Access Level Instance to move {{ data.viewIds.length }} + {{ data.viewIds.length === 1 ? 'property' : 'properties' }} to.
@@ -26,7 +24,6 @@
-
- - -
\ No newline at end of file +
+ +
diff --git a/src/app/modules/inventory/actions/analysis-config/better-config.component.html b/src/app/modules/inventory/actions/analysis-config/better-config.component.html index d8228680..649ed6a3 100644 --- a/src/app/modules/inventory/actions/analysis-config/better-config.component.html +++ b/src/app/modules/inventory/actions/analysis-config/better-config.component.html @@ -1,50 +1,44 @@ -
- -
- The BETTER analysis leverages better.lbl.gov to calculate energy, cost, and GHG emission savings by comparing the - property's change point model with a benchmarked model. The results include saving potential and a list of recommended - high-level energy conservation measures. + +
+ The BETTER analysis leverages better.lbl.gov to calculate energy, cost, and GHG emission savings by comparing the property's change + point model with a benchmarked model. The results include saving potential and a list of recommended high-level energy conservation + measures.
- + Savings Target @for (option of savingsTargets; track option) { - {{ option }} + {{ option }} } - - + + Benchmark Data Type @for (option of benchmarkDataTypes; track option) { - {{ option }} + {{ option }} } - + Min Model R² - +
- -
- - Preprocess Meters - +
+ Preprocess Meters @if (viewIds.length > 1) { - - Portfolio Analysis - + Portfolio Analysis }
- + Cycle Meter Data Range All Meter Data @@ -60,18 +54,18 @@ } @else if (form.value.select_meters === 'date_range') { -
+
Start Date - - + + End Date - - + + @if (form.get('meter.end_date')?.hasError('dateBefore')) { End date must be after start date. @@ -79,4 +73,4 @@
} - \ No newline at end of file + diff --git a/src/app/modules/inventory/actions/analysis-config/bur-config.component.html b/src/app/modules/inventory/actions/analysis-config/bur-config.component.html index 24fb4f59..6877cb56 100644 --- a/src/app/modules/inventory/actions/analysis-config/bur-config.component.html +++ b/src/app/modules/inventory/actions/analysis-config/bur-config.component.html @@ -1,34 +1,35 @@ -
- -
- The Building Upgrade Recommendation analysis implements a workflow to identify buildings that may need a deep energy retrofit, equipment replaced or re-tuning based on building attributes such as energy use, year built, and square footage. If your organization contains elements, the Element Statistics Analysis should be run prior to running this analysis. + +
+ The Building Upgrade Recommendation analysis implements a workflow to identify buildings that may need a deep energy retrofit, equipment + replaced or re-tuning based on building attributes such as energy use, year built, and square footage. If your organization contains + elements, the Element Statistics Analysis should be run prior to running this analysis.
@for (field of fields; track field.name) { - - {{ field.label }} + + {{ field.label }} - @if (field.type === 'select') { - - @for (col of field.options; track $index) { - {{ col.display_name }} + @if (field.type === 'select') { + + @for (col of field.options; track $index) { + {{ col.display_name }} + } + + } @else { + } - - } @else { - - } - @if (field.hint) { - {{ field.hint }} - } - + @if (field.hint) { + {{ field.hint }} + } + } - \ No newline at end of file + diff --git a/src/app/modules/inventory/actions/analysis-config/simple-config.component.html b/src/app/modules/inventory/actions/analysis-config/simple-config.component.html index e817b398..a9d51d2e 100644 --- a/src/app/modules/inventory/actions/analysis-config/simple-config.component.html +++ b/src/app/modules/inventory/actions/analysis-config/simple-config.component.html @@ -1,69 +1,62 @@ -
+
{{ aboutMap[service] }}
- -@if (service === 'BSyncr') { +@if (service === 'BSyncr') { -
+ BSyncr Model Selection @for (option of bsyncrModelOptions; track option) { - {{ option }} + {{ option }} }
} @else if (service === 'CO2') { - -
- - Save Results to Property - + + Save Results to Property
- } @else if (service === 'EUI') { - -
- + + Cycle Meter Data Range All Meter Data @if (form.value.select_meters === 'select_cycle') { - - Cycle - - @for (cycle of cycles; track cycle) { - {{ cycle.name }} - } - - - } @else if (form.value.select_meters === 'date_range') { -
- - Start Date - - - + + Cycle + + @for (cycle of cycles; track cycle) { + {{ cycle.name }} + } + + } @else if (form.value.select_meters === 'date_range') { +
+ + Start Date + + + + - - End Date - - - - @if (form.get('meter.end_date')?.hasError('dateBefore')) { - End date must be after start date. - } - -
+ + End Date + + + + @if (form.get('meter.end_date')?.hasError('dateBefore')) { + End date must be after start date. + } + +
} - -} \ No newline at end of file +} diff --git a/src/app/modules/inventory/actions/analysis-run-modal.component.html b/src/app/modules/inventory/actions/analysis-run-modal.component.html index 5beaf07a..df28148d 100644 --- a/src/app/modules/inventory/actions/analysis-run-modal.component.html +++ b/src/app/modules/inventory/actions/analysis-run-modal.component.html @@ -1,26 +1,21 @@ - + @if (!runningAnalysis) { -
-
+ Group Name - - @if (form.controls.name?.hasError('valueExists')) { - This name already exists. - } + + @if (form.controls.name?.hasError('valueExists')) { + This name already exists. + } Service @for (service of serviceTypes; track service) { - {{ service.display }} + {{ service.display }} } @@ -28,43 +23,35 @@ @if (service === 'BETTER') { } @else if (service === 'Building Upgrade Recommendation') { - + } @else if (service) { - + } @if (['BETTER', 'Building Upgrade Recommendation'].includes(service)) {
-
+
} -
} @else { } - -
- @if(!runningAnalysis) { - +
+ @if (!runningAnalysis) { + } - -
\ No newline at end of file +
diff --git a/src/app/modules/inventory/actions/groups-modal.component.html b/src/app/modules/inventory/actions/groups-modal.component.html index 04ad9740..6b814860 100644 --- a/src/app/modules/inventory/actions/groups-modal.component.html +++ b/src/app/modules/inventory/actions/groups-modal.component.html @@ -1,14 +1,9 @@ - + @if (loading) {
- +
-} -@else if (allSameAli) { - +} @else if (allSameAli) {
Not seeing the right group? Create a new one.
- - + + Group Name - + @if (form.controls.name?.hasError('valueExists')) { This name already exists. } - - +
- } @else { - - Selection includes multiple Access Level Instances. To update or create a group, all properties must be in the same access level instance. Modify selection and try again. - + Selection includes multiple Access Level Instances. To update or create a group, all properties must be in the same access level + instance. Modify selection and try again. } -
+
- -
\ No newline at end of file +
diff --git a/src/app/modules/inventory/actions/labels-modal.component.html b/src/app/modules/inventory/actions/labels-modal.component.html index 26915a78..6c96db60 100644 --- a/src/app/modules/inventory/actions/labels-modal.component.html +++ b/src/app/modules/inventory/actions/labels-modal.component.html @@ -1,15 +1,42 @@ - + \ No newline at end of file +> + +
+ +
Not seeing the right Label? Create a new one.
+
+ + Label Name + + + + + Color + + +
{{ form.controls.color?.value }}
+
+ @for (c of colors; track c) { + +
{{ c }}
+
+ } +
+
+ + Show in list + +
+
+ +
+ +
diff --git a/src/app/modules/inventory/actions/labels-modal.component.ts b/src/app/modules/inventory/actions/labels-modal.component.ts index 6c0c8c45..d614f12f 100644 --- a/src/app/modules/inventory/actions/labels-modal.component.ts +++ b/src/app/modules/inventory/actions/labels-modal.component.ts @@ -1,17 +1,18 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit} from '@angular/core' import { Component, inject } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import type { Label} from '@seed/api' +import type { Label, LabelColor} from '@seed/api' import { LabelService } from '@seed/api' import { ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { AgGridAngular } from 'ag-grid-angular' -import { Subject, takeUntil, tap } from 'rxjs' +import { Subject, switchMap, takeUntil, tap } from 'rxjs' import { InventoryType } from '../inventory.types' import { CellValueChangedEvent, ColDef } from 'ag-grid-community' import { ConfigService } from '@seed/services' +import { SEEDValidators } from '@seed/validators' @Component({ selector: 'seed-labels-modal', @@ -30,17 +31,30 @@ export class LabelsModalComponent implements OnInit, OnDestroy { private _dialogRef = inject(MatDialogRef) private _configService = inject(ConfigService) private _labelService = inject(LabelService) + colors: LabelColor[] = ['red', 'orange', 'blue', 'light blue', 'green', 'gray'] columnDefs: ColDef[] - labels: Label[] = [] + existingNames: string[] = [] gridTheme$ = this._configService.gridTheme$ + gridHeight = 0 + labels: Label[] = [] + newLabel: Label + rowData: (Label & { add: boolean; remove: boolean })[] = [] data = inject(MAT_DIALOG_DATA) as { orgId: number; type: InventoryType; viewIds: number[] } + form = new FormGroup({ + organization_id: new FormControl(this.data.orgId), + name: new FormControl(null), + color: new FormControl('gray'), + show_in_list: new FormControl(true), + }) + ngOnInit(): void { this._labelService.labels$ .pipe( tap((labels) => { this.labels = labels + this.setValidator() this.setGrid() }), takeUntil(this._unsubscribeAll$), @@ -48,14 +62,49 @@ export class LabelsModalComponent implements OnInit, OnDestroy { .subscribe() } + setValidator() { + this.existingNames = this.labels.map((g) => g.name) + const nameCtrl = this.form.get('name') + nameCtrl?.setValidators([ + SEEDValidators.uniqueValue(this.existingNames), + ]) + } + setGrid() { + this.getGridHeight() + this.setColDefs() + this.setRowData() + } + + setRowData() { + this.rowData = this.labels.map((group) => ({ + ...group, + add: group.id === this.newLabel?.id, + remove: false, + })) + + this.newLabel = null + } + + setColDefs() { this.columnDefs = [ - { field: 'name', headerName: 'Label', flex: 1 }, - { field: 'add', headerName: 'Add', flex: 0.5, editable: true }, - { field: 'remove', headerName: 'Remove', flex: 0.5, editable: true }, + { + field: 'name', + headerName: 'Label', + flex: 1, + cellRenderer: this.labelRenderer, + }, + { field: 'add', headerName: 'Add', flex: 0.2, editable: true }, + { field: 'remove', headerName: 'Remove', flex: 0.2, editable: true }, ] } + labelRenderer({ data }: { data: Label }) { + return ` +
${data.name}
+ ` + } + onCellValueChanged(event: CellValueChangedEvent): void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { colDef, newValue, node } = event @@ -67,8 +116,43 @@ export class LabelsModalComponent implements OnInit, OnDestroy { node.setDataValue(otherField, false) } } + + getGridHeight() { + this.gridHeight = Math.min(this.labels.length * 42 + 52, 500) + } + + onSubmit() { + const data = this.form.value as Label + this._labelService.create(data) + .pipe( + tap((label) => { this.newLabel = label }), + switchMap(() => this._labelService.getByOrgId(data.organization_id)), + tap(() => { this.form.reset() }), + ) + .subscribe() + } + + done() { + const { orgId, viewIds, type } = this.data + const addLabelIds: number[] = this.rowData.filter((g) => g.add).map((g) => g.id) + const removeLabelIds: number[] = this.rowData.filter((g) => g.remove).map((g) => g.id) + + if (!addLabelIds.length && !removeLabelIds.length) { + this.close() + return + } + + this._labelService.updateLabelInventory(orgId, viewIds, type, addLabelIds, removeLabelIds).subscribe(() => { + this.close(true) + }) + } + + close(success = false) { + this._dialogRef.close(success) + } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() } -} \ No newline at end of file +} diff --git a/src/app/modules/organizations/labels/modal/form-modal.component.html b/src/app/modules/organizations/labels/modal/form-modal.component.html index ce137067..3d11cd2b 100644 --- a/src/app/modules/organizations/labels/modal/form-modal.component.html +++ b/src/app/modules/organizations/labels/modal/form-modal.component.html @@ -15,6 +15,9 @@ Color + +
{{ form.controls.color?.value }}
+
@for (c of colors; track c) {
{{ c }}
Date: Mon, 28 Jul 2025 18:33:06 +0000 Subject: [PATCH 12/30] export modal fxnal --- src/@seed/api/inventory/inventory.service.ts | 23 +++- .../progress/progress-bar.component.html | 2 +- .../list/grid/actions.component.html | 1 + .../list/grid/actions.component.ts | 10 +- .../actions/export-modal.component.html | 54 +++++++++ .../actions/export-modal.component.ts | 114 ++++++++++++++++++ src/app/modules/inventory/actions/index.ts | 1 + src/app/modules/inventory/inventory.types.ts | 10 ++ 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src/app/modules/inventory/actions/export-modal.component.html create mode 100644 src/app/modules/inventory/actions/export-modal.component.ts diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index 6bd15547..47538cde 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, tap, throwError } from 'rxjs' +import { BehaviorSubject, catchError, map, take, tap, throwError } from 'rxjs' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { @@ -12,6 +12,7 @@ import type { GenericView, GenericViewsResponse, InventoryDisplayType, + InventoryExportData, InventoryType, InventoryTypeGoal, NewProfileData, @@ -350,4 +351,24 @@ export class InventoryService { }), ) } + + startInventoryExport(orgId: number): Observable { + const url = `/api/v3/tax_lot_properties/start_export/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + take(1), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting export') + }), + ) + } + + exportInventory(orgId: number, type: InventoryType, data: InventoryExportData): Observable { + const url = `/api/v3/tax_lot_properties/export/?inventory_type=${type}&organization_id=${orgId}` + return this._httpClient.post(url, data, { responseType: 'blob' }).pipe( + take(1), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting export') + }), + ) + } } diff --git a/src/@seed/components/progress/progress-bar.component.html b/src/@seed/components/progress/progress-bar.component.html index 1e31e892..2e02da7f 100644 --- a/src/@seed/components/progress/progress-bar.component.html +++ b/src/@seed/components/progress/progress-bar.component.html @@ -15,7 +15,7 @@
diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index b62dd674..26643ce4 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -23,6 +23,7 @@ icon="fa-solid:share-nodes" > + Access Levels 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 4b7c0716..a9678428 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -9,7 +9,7 @@ import { DeleteModalComponent, MenuItemComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.component' import { DQCStartModalComponent } from 'app/modules/data-quality' -import { AliChangeModalComponent, AnalysisRunModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' +import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' import { UpdateDerivedDataComponent } from '../actions' @@ -121,6 +121,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openExportModal() { + const dialogRef = this._dialog.open(ExportModalComponent, { + width: '40rem', + data: { ...this.baseData(), profileId: this.profile?.id || null }, + }) + this.afterClosed(dialogRef) + } + openAliChangeModal() { const dialogRef = this._dialog.open(AliChangeModalComponent, { width: '40rem', diff --git a/src/app/modules/inventory/actions/export-modal.component.html b/src/app/modules/inventory/actions/export-modal.component.html new file mode 100644 index 00000000..344bba3a --- /dev/null +++ b/src/app/modules/inventory/actions/export-modal.component.html @@ -0,0 +1,54 @@ + + + +
+ + + +
+ + Name + + + + + CSV + BuildingSync (Excel) + GeoJSON + + + @if (form.value.export_type === 'geojson') { + Include Meter Readings (Only + recommended for small exports) + } + + @else if (form.value.export_type === 'csv') { + Include Label Header + } + + @else { +
+ } +
+ +
+ +
+
+ + + + + + +
+
+ diff --git a/src/app/modules/inventory/actions/export-modal.component.ts b/src/app/modules/inventory/actions/export-modal.component.ts new file mode 100644 index 00000000..86fbd81c --- /dev/null +++ b/src/app/modules/inventory/actions/export-modal.component.ts @@ -0,0 +1,114 @@ +import type { OnDestroy } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import type { MatStepper } from '@angular/material/stepper' +import { catchError, combineLatest, EMPTY, finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' +import { InventoryService } from '@seed/api' +import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { UploaderService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { InventoryExportData, InventoryType } from '../inventory.types' + +@Component({ + selector: 'seed-export-modal', + templateUrl: './export-modal.component.html', + imports: [FormsModule, MaterialImports, ModalHeaderComponent, ProgressBarComponent, ReactiveFormsModule], +}) +export class ExportModalComponent implements OnDestroy { + @ViewChild('stepper') stepper!: MatStepper + private _dialogRef = inject(MatDialogRef) + private _inventoryService = inject(InventoryService) + private _snackBar = inject(SnackBarService) + private _uploaderService = inject(UploaderService) + private readonly _unsubscribeAll$ = new Subject() + + progressBarObj = this._uploaderService.defaultProgressBarObj + filename: string + exportData: InventoryExportData + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + type: InventoryType; + viewIds: number[]; + profileId: number; + } + + form = new FormGroup({ + name: new FormControl(null, Validators.required), + include_notes: new FormControl(true), + export_type: new FormControl<'csv' | 'xlsx' | 'geojson'>('csv', Validators.required), + include_label_header: new FormControl(false), + include_meter_readings: new FormControl(false), + }) + + export() { + this._inventoryService.startInventoryExport(this.data.orgId) + .pipe( + tap(({ progress_key }) => { this.initExport(progress_key) }), + switchMap(() => this.pollExport()), + tap((response) => { this.downloadData(response[0]) }), + takeUntil(this._unsubscribeAll$), + catchError(() => { return EMPTY }), + finalize(() => { this.close() }), + ) + .subscribe() + } + + initExport(progress_key: string) { + this.stepper.next() + this.formatFilename() + this.formatExportData(this.filename, progress_key) + } + + pollExport() { + const { orgId, type } = this.data + return combineLatest([ + this._inventoryService.exportInventory(orgId, type, this.exportData), + this._uploaderService.checkProgressLoop({ + progressKey: this.exportData.progress_key, + progressBarObj: this.progressBarObj, + }), + ]) + } + + downloadData(data: Blob) { + const a = document.createElement('a') + const url = URL.createObjectURL(data) + a.href = url + a.download = this.exportData.filename + a.click() + URL.revokeObjectURL(url) + this._snackBar.success(`Exported ${this.exportData.filename}`) + } + + formatFilename() { + this.filename = this.form.value.name + const ext = `.${this.form.value.export_type}` + if (!this.filename.endsWith(ext)) { + this.filename += ext + } + } + + formatExportData(filename: string, progress_key: string) { + this.exportData = { + export_type: this.form.value.export_type, + filename, + ids: this.data.viewIds, + include_meter_readings: this.form.value.include_meter_readings, + include_notes: this.form.value.include_notes, + profile_id: this.data.profileId, + progress_key, + } + } + + close() { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory/actions/index.ts b/src/app/modules/inventory/actions/index.ts index 98a2b7fc..867ca2c3 100644 --- a/src/app/modules/inventory/actions/index.ts +++ b/src/app/modules/inventory/actions/index.ts @@ -3,3 +3,4 @@ export * from './analysis-config' export * from './analysis-run-modal.component' export * from './groups-modal.component' export * from './labels-modal.component' +export * from './export-modal.component' diff --git a/src/app/modules/inventory/inventory.types.ts b/src/app/modules/inventory/inventory.types.ts index e6396557..2f39f5ed 100644 --- a/src/app/modules/inventory/inventory.types.ts +++ b/src/app/modules/inventory/inventory.types.ts @@ -255,3 +255,13 @@ export type PropertyDocumentType = 'application/pdf' | 'application/dxf' | 'text export type PropertyDocumentExtension = 'PDF' | 'DXF' | 'IDF' | 'OSM' export type CrossCyclesResponse = Record[]> + +export type InventoryExportData = { + export_type: 'csv' | 'xlsx' | 'geojson'; + filename: string; + ids: number[]; + include_meter_readings: boolean; + include_notes: boolean; + profile_id: number; + progress_key: string; +} From b75312634545eea77ae52a5500e10a92845b1ee1 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 28 Jul 2025 19:44:02 +0000 Subject: [PATCH 13/30] refresh metadata fxnal --- src/@seed/api/inventory/inventory.service.ts | 26 +++++++ .../inventory-list/list/actions/index.ts | 3 +- .../refresh-metadata-modal.component.html | 24 ++++++ .../refresh-metadata-modal.component.ts | 77 +++++++++++++++++++ .../list/grid/actions.component.html | 6 +- .../list/grid/actions.component.ts | 10 ++- 6 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html create mode 100644 src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index 47538cde..35733f00 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -371,4 +371,30 @@ export class InventoryService { }), ) } + + startRefreshMetadata(orgId: number): Observable { + const url = `/api/v3/tax_lot_properties/start_set_update_to_now/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + take(1), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting metadata refresh') + }), + ) + } + + refreshMetadata(orgId: number, propertyViews: number[], taxlotViews: number[], progressKey: string): Observable { + const url = '/api/v3/tax_lot_properties/set_update_to_now/' + const data = { + organization_id: orgId, + property_views: propertyViews, + taxlot_views: taxlotViews, + progress_key: progressKey, + } + return this._httpClient.post(url, data).pipe( + take(1), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error refreshing metadata') + }), + ) + } } diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index 1b8b159a..cf0e09bb 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1 +1,2 @@ -export * from './update-derived-data.component' \ No newline at end of file +export * from './refresh-metadata-modal.component' +export * from './update-derived-data.component' diff --git a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html new file mode 100644 index 00000000..dad2d904 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html @@ -0,0 +1,24 @@ + + + +@if (!inProgress) { +
+ This will set the selected inventory's 'Updated' timestamp to {{ currentTime }}. +
+} + +@else { + +} + +
+ +
\ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts new file mode 100644 index 00000000..b79ff833 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts @@ -0,0 +1,77 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { InventoryService } from '@seed/api' +import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { UploaderService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { InventoryType } from 'app/modules/inventory/inventory.types' +import { combineLatest, filter, finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' + +@Component({ + selector: 'seed-refresh-metadata-modal', + templateUrl: './refresh-metadata-modal.component.html', + imports: [MaterialImports, ModalHeaderComponent, ProgressBarComponent], +}) +export class RefreshMetadataModalComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _inventoryService = inject(InventoryService) + private _snackBar = inject(SnackBarService) + private _uploaderService = inject(UploaderService) + private _unsubscribeAll$ = new Subject() + currentTime: string + inProgress = false + progressBarObj = this._uploaderService.defaultProgressBarObj + progressKey: string + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit() { + setInterval(() => { + this.currentTime = new Date().toLocaleTimeString() + }, 1000) + } + + refresh() { + this.inProgress = true + this._inventoryService.startRefreshMetadata(this.data.orgId) + .pipe( + switchMap(({ progress_key }) => this.pollRefresh(progress_key)), + filter(([_, progressResponse]) => progressResponse.progress >= 100), + tap(() => { + this._snackBar.success('Success') + this.close(true) + }), + takeUntil(this._unsubscribeAll$), + finalize(() => { this.inProgress = false }), + ) + .subscribe() + } + + pollRefresh(progress_key: string) { + const { orgId, type, viewIds } = this.data + const [propertyViews, taxlotViews] = type == 'taxlots' ? [[], viewIds] : [viewIds, []] + + return combineLatest([ + this._inventoryService.refreshMetadata(orgId, propertyViews, taxlotViews, progress_key), + this._uploaderService.checkProgressLoop({ + progressKey: progress_key, + progressBarObj: this.progressBarObj, + }), + ]) + } + + close(success = false): void { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 26643ce4..fd05901e 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -80,7 +80,6 @@ Other - 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 a9678428..8495f964 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -11,7 +11,7 @@ import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.comp import { DQCStartModalComponent } from 'app/modules/data-quality' import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { UpdateDerivedDataComponent } from '../actions' +import { RefreshMetadataModalComponent, UpdateDerivedDataComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', @@ -178,6 +178,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openRefreshMetadataModal() { + const dialogRef = this._dialog.open(RefreshMetadataModalComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + afterClosed(dialogRef: MatDialogRef) { dialogRef.afterClosed().pipe( filter(Boolean), From e0e5b2b61558a70535ba0306cab3b63208f63421 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 29 Jul 2025 17:21:37 +0000 Subject: [PATCH 14/30] geocode in progress --- src/@seed/api/geocode/geocode.service.ts | 72 ++++++++++++++++ src/@seed/api/geocode/geocode.types.ts | 18 ++++ src/@seed/api/geocode/index.ts | 2 + src/@seed/api/index.ts | 1 + .../list/actions/geocode-modal.component.html | 40 +++++++++ .../list/actions/geocode-modal.component.ts | 84 +++++++++++++++++++ .../inventory-list/list/actions/index.ts | 1 + .../list/grid/actions.component.html | 2 +- .../list/grid/actions.component.ts | 10 ++- 9 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 src/@seed/api/geocode/geocode.service.ts create mode 100644 src/@seed/api/geocode/geocode.types.ts create mode 100644 src/@seed/api/geocode/index.ts create mode 100644 src/app/modules/inventory-list/list/actions/geocode-modal.component.html create mode 100644 src/app/modules/inventory-list/list/actions/geocode-modal.component.ts diff --git a/src/@seed/api/geocode/geocode.service.ts b/src/@seed/api/geocode/geocode.service.ts new file mode 100644 index 00000000..f2da24a6 --- /dev/null +++ b/src/@seed/api/geocode/geocode.service.ts @@ -0,0 +1,72 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { ErrorService } from '@seed/services' +import type { InventoryType } from 'app/modules/inventory' +import type { Observable } from 'rxjs' +import { catchError } from 'rxjs' +import type { ConfidenceSummary, GeocodingColumns } from './geocode.types' + +@Injectable({ providedIn: 'root' }) +export class GeocodeService { + private _httpClient = inject(HttpClient) + private _errorService = inject(ErrorService) + + geocode(orgId: number, viewIds: number[], type: InventoryType): Observable { + const url = `/api/v3/geocode/geocode_by_ids/&organization_id=${orgId}` + const data = { + property_view_ids: type === 'taxlots' ? [] : viewIds, + taxlot_view_ids: type === 'taxlots' ? viewIds : [], + } + return this._httpClient.post(url, data) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Geocode Error') + }), + ) + } + + confidenceSummary(orgId: number, viewIds: number[], type: InventoryType): Observable { + const url = `/api/v3/geocode/confidence_summary/?organization_id=${orgId}` + const data = { + property_view_ids: type === 'taxlots' ? [] : viewIds, + taxlot_view_ids: type === 'taxlots' ? viewIds : [], + } + return this._httpClient.post(url, data) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Geocode Confidence Summary Error') + }), + ) + } + + checkApiKey(orgId: number): Observable { + const url = `/api/v3/organizations/${orgId}/geocode_api_key_exists/` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Geocode API Key Check Error') + }), + ) + } + + geocodingEnabled(orgId: number): Observable { + const url = `/api/v3/organizations/${orgId}/geocoding_enabled/` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Geocoding Enabled Check Error') + }), + ) + } + + geocodingColumns(orgId: number): Observable { + const url = `/api/v3/organizations/${orgId}/geocoding_columns/` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Geocoding Columns Error') + }), + ) + } +} diff --git a/src/@seed/api/geocode/geocode.types.ts b/src/@seed/api/geocode/geocode.types.ts new file mode 100644 index 00000000..a9c3206b --- /dev/null +++ b/src/@seed/api/geocode/geocode.types.ts @@ -0,0 +1,18 @@ +export type ConfidenceSummary = { + properties: InventoryConfidenceSummary; + taxlots: InventoryConfidenceSummary; +} + +export type InventoryConfidenceSummary = { + census_geocoder: number; + high_confidence: number; + low_confidence: number; + manual: number; + missing_address_components: number; + not_geocoded: number; +} + +export type GeocodingColumns = { + PropertyState: string[]; // column_name + TaxLotState: string[]; +} diff --git a/src/@seed/api/geocode/index.ts b/src/@seed/api/geocode/index.ts new file mode 100644 index 00000000..6ec3e9a2 --- /dev/null +++ b/src/@seed/api/geocode/index.ts @@ -0,0 +1,2 @@ +export * from './geocode.service' +export * from './geocode.types' diff --git a/src/@seed/api/index.ts b/src/@seed/api/index.ts index bfa115f2..a7a17037 100644 --- a/src/@seed/api/index.ts +++ b/src/@seed/api/index.ts @@ -7,6 +7,7 @@ export * from './cycle' export * from './data-quality' export * from './dataset' export * from './derived-column' +export * from './geocode' export * from './groups' export * from './inventory' export * from './label' diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html new file mode 100644 index 00000000..adba7826 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -0,0 +1,40 @@ + + + + +
+ @if (!hasApiKey) { + +
+ {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} +
+
+ {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }} +
+
+ } + + @if (!geocodingEnabled) { + + {{ t('Geocoding has been disabled for this organization.') }} + + } + + + + + + +
+ +
+ +
\ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts new file mode 100644 index 00000000..a21c5b19 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -0,0 +1,84 @@ +import type { OnDestroy, OnInit} from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { GeocodeService } from '@seed/api' +import type { ConfidenceSummary, GeocodingColumns } from '@seed/api/geocode/geocode.types' +import { AlertComponent, ModalHeaderComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory/inventory.types' +import { forkJoin, Subject, tap } from 'rxjs' + +@Component({ + selector: 'seed-geocode-modal', + templateUrl: './geocode-modal.component.html', + imports: [AlertComponent, MaterialImports, ModalHeaderComponent, SharedImports], +}) +export class GeocodeModalComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _geocodeService = inject(GeocodeService) + private _unsubscribeAll$ = new Subject() + + confidenceSummary: ConfidenceSummary + geocodingEnabled = true + hasApiKey = true + hasEnoughGeoCols = true + hasGeoColumns = true + suggestVerify = true + notGeocoded = false + + geocodeState: 'verify' | 'geocode' | 'result' | 'fail' = 'verify' + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + get valid() { + return this.hasApiKey && this.geocodingEnabled && this.hasGeoColumns + } + + ngOnInit(): void { + this.getGeocodeConfig() + } + + getGeocodeConfig() { + forkJoin([ + this._geocodeService.checkApiKey(this.data.orgId), + this._geocodeService.geocodingEnabled(this.data.orgId), + this._geocodeService.geocodingColumns(this.data.orgId), + this._geocodeService.confidenceSummary(this.data.orgId, this.data.viewIds, this.data.type), + ]).pipe( + tap(([hasApiKey, geocodingEnabled, geoColumns, confidenceSummary]) => { + this.hasApiKey = hasApiKey + this.geocodingEnabled = geocodingEnabled + this.processGeoColumns(geoColumns) + this.processConfidenceSummary(confidenceSummary) + this.suggestVerify = hasApiKey && this.hasGeoColumns && this.geocodeState === 'verify' + }), + ).subscribe() + } + + processGeoColumns({ PropertyState, TaxLotState }: GeocodingColumns) { + this.hasGeoColumns = this.data.type === 'taxlots' ? TaxLotState.length > 0 : PropertyState.length > 0 + } + + processConfidenceSummary(confidenceSummary: ConfidenceSummary) { + this.confidenceSummary = confidenceSummary + // Process the confidence summary as needed + } + + close(success = false) { + this._dialogRef.close(success) + } + + onSubmit() { + console.log('submit') + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index cf0e09bb..53534b5c 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,2 +1,3 @@ +export * from './geocode-modal.component' export * from './refresh-metadata-modal.component' export * from './update-derived-data.component' diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index fd05901e..4ef21c6f 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -86,7 +86,7 @@ (action)="tempAction()" label="FEMP CTS Export" > - + ) { dialogRef.afterClosed().pipe( filter(Boolean), From c04f206bdee0d5b1d1a67c67b17b7afbd391e6d3 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 29 Jul 2025 17:33:20 +0000 Subject: [PATCH 15/30] lint --- cspell.json | 1 + src/@seed/api/geocode/geocode.service.ts | 4 +-- .../progress/progress-bar.component.html | 6 +--- .../data-quality/start-modal.component.ts | 4 +-- .../list/actions/geocode-modal.component.html | 17 ++++------- .../list/actions/geocode-modal.component.ts | 4 +-- .../refresh-metadata-modal.component.html | 16 +++-------- .../refresh-metadata-modal.component.ts | 2 +- .../actions/update-derived-data.component.ts | 2 +- .../list/grid/actions.component.html | 6 +--- .../actions/ali-change-modal.component.ts | 2 +- .../analysis-config/bur-config.component.ts | 2 +- .../actions/export-modal.component.html | 28 +++++++------------ .../actions/groups-modal.component.ts | 6 ++-- .../actions/labels-modal.component.ts | 12 ++++---- src/styles/styles.scss | 6 ++-- 16 files changed, 44 insertions(+), 74 deletions(-) diff --git a/cspell.json b/cspell.json index c4e6b523..a9ae3264 100644 --- a/cspell.json +++ b/cspell.json @@ -25,6 +25,7 @@ "CEJST", "eeej", "EPSG", + "EISA", "FEMP", "falsey", "greenbutton", diff --git a/src/@seed/api/geocode/geocode.service.ts b/src/@seed/api/geocode/geocode.service.ts index f2da24a6..5e3fc35e 100644 --- a/src/@seed/api/geocode/geocode.service.ts +++ b/src/@seed/api/geocode/geocode.service.ts @@ -1,10 +1,10 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' -import { ErrorService } from '@seed/services' -import type { InventoryType } from 'app/modules/inventory' import type { Observable } from 'rxjs' import { catchError } from 'rxjs' +import { ErrorService } from '@seed/services' +import type { InventoryType } from 'app/modules/inventory' import type { ConfidenceSummary, GeocodingColumns } from './geocode.types' @Injectable({ providedIn: 'root' }) diff --git a/src/@seed/components/progress/progress-bar.component.html b/src/@seed/components/progress/progress-bar.component.html index 2e02da7f..092ea6be 100644 --- a/src/@seed/components/progress/progress-bar.component.html +++ b/src/@seed/components/progress/progress-bar.component.html @@ -12,11 +12,7 @@ }
- +
@if (showSubProgress && subProgress && subProgress < 100) { diff --git a/src/app/modules/data-quality/start-modal.component.ts b/src/app/modules/data-quality/start-modal.component.ts index 069bb0d9..f0ed9c62 100644 --- a/src/app/modules/data-quality/start-modal.component.ts +++ b/src/app/modules/data-quality/start-modal.component.ts @@ -1,11 +1,11 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog' +import { switchMap, takeUntil, tap } from 'rxjs' +import { Subject } from 'rxjs/internal/Subject' import { DataQualityService } from '@seed/api' import { ProgressBarComponent } from '@seed/components' import { UploaderService } from '@seed/services/uploader/uploader.service' -import { switchMap, takeUntil, tap } from 'rxjs' -import { Subject } from 'rxjs/internal/Subject' import type { InventoryType } from '../inventory' import { DQCResultsModalComponent } from './results-modal.component' diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html index adba7826..225952bf 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -1,25 +1,20 @@ - - +
@if (!hasApiKey) { - +
{{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }}
-
+
{{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }}
} @if (!geocodingEnabled) { - + {{ t('Geocoding has been disabled for this organization.') }} } @@ -31,10 +26,8 @@ - -
-
\ No newline at end of file +
diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index a21c5b19..992204b2 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -1,13 +1,13 @@ -import type { OnDestroy, OnInit} from '@angular/core' +import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { forkJoin, Subject, tap } from 'rxjs' import { GeocodeService } from '@seed/api' import type { ConfidenceSummary, GeocodingColumns } from '@seed/api/geocode/geocode.types' import { AlertComponent, ModalHeaderComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' import type { InventoryType } from 'app/modules/inventory/inventory.types' -import { forkJoin, Subject, tap } from 'rxjs' @Component({ selector: 'seed-geocode-modal', diff --git a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html index dad2d904..c847261c 100644 --- a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html @@ -1,17 +1,9 @@ - - + @if (!inProgress) { -
- This will set the selected inventory's 'Updated' timestamp to {{ currentTime }}. -
-} +
This will set the selected inventory's 'Updated' timestamp to {{ currentTime }}.
-@else { +} @else { -
\ No newline at end of file +
diff --git a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts index b79ff833..c6fa0a18 100644 --- a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.ts @@ -1,13 +1,13 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { combineLatest, filter, finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' import { InventoryService } from '@seed/api' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { UploaderService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory/inventory.types' -import { combineLatest, filter, finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-refresh-metadata-modal', diff --git a/src/app/modules/inventory-list/list/actions/update-derived-data.component.ts b/src/app/modules/inventory-list/list/actions/update-derived-data.component.ts index 77559051..80460b60 100644 --- a/src/app/modules/inventory-list/list/actions/update-derived-data.component.ts +++ b/src/app/modules/inventory-list/list/actions/update-derived-data.component.ts @@ -2,12 +2,12 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject, ViewChild } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import type { MatStepper } from '@angular/material/stepper' +import { Subject, switchMap } from 'rxjs' import { InventoryService } from '@seed/api' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { UploaderService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import { Subject, switchMap } from 'rxjs' @Component({ selector: 'seed-update-derived-data', diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 4ef21c6f..7efe75a4 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -87,11 +87,7 @@ label="FEMP CTS Export" > - + Salesforce diff --git a/src/app/modules/inventory/actions/ali-change-modal.component.ts b/src/app/modules/inventory/actions/ali-change-modal.component.ts index 94689ea6..99949a40 100644 --- a/src/app/modules/inventory/actions/ali-change-modal.component.ts +++ b/src/app/modules/inventory/actions/ali-change-modal.component.ts @@ -3,11 +3,11 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { combineLatest, Subject, take, takeUntil, tap } from 'rxjs' import type { AccessLevelInstancesByDepth, AccessLevelsByDepth } from '@seed/api' import { InventoryService, OrganizationService } from '@seed/api' import { ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' -import { combineLatest, Subject, take, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-ali-change-modal', diff --git a/src/app/modules/inventory/actions/analysis-config/bur-config.component.ts b/src/app/modules/inventory/actions/analysis-config/bur-config.component.ts index d39bbe8f..54a29091 100644 --- a/src/app/modules/inventory/actions/analysis-config/bur-config.component.ts +++ b/src/app/modules/inventory/actions/analysis-config/bur-config.component.ts @@ -2,11 +2,11 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' -import { naturalSort } from '@seed/utils' import { Subject, takeUntil, tap } from 'rxjs' import type { Column } from '@seed/api' import { ColumnService } from '@seed/api' import { MaterialImports } from '@seed/materials' +import { naturalSort } from '@seed/utils' @Component({ selector: 'seed-bur-config', diff --git a/src/app/modules/inventory/actions/export-modal.component.html b/src/app/modules/inventory/actions/export-modal.component.html index 344bba3a..37f17567 100644 --- a/src/app/modules/inventory/actions/export-modal.component.html +++ b/src/app/modules/inventory/actions/export-modal.component.html @@ -1,15 +1,10 @@ - - +
-
+ Name @@ -22,21 +17,20 @@ @if (form.value.export_type === 'geojson') { - Include Meter Readings (Only - recommended for small exports) - } + Include Meter Readings (Only recommended for small exports) + - @else if (form.value.export_type === 'csv') { - Include Label Header - } + } @else if (form.value.export_type === 'csv') { + Include Label Header - @else { -
+ } @else { +
}
- +
@@ -48,7 +42,5 @@ [total]="progressBarObj.total" > -
- diff --git a/src/app/modules/inventory/actions/groups-modal.component.ts b/src/app/modules/inventory/actions/groups-modal.component.ts index dbcc7c91..e669449c 100644 --- a/src/app/modules/inventory/actions/groups-modal.component.ts +++ b/src/app/modules/inventory/actions/groups-modal.component.ts @@ -3,16 +3,16 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { CurrentUser, InventoryGroup } from '@seed/api' import { GroupsService, OrganizationService, UserService } from '@seed/api' import { AlertComponent, ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ConfigService } from '@seed/services' import { SEEDValidators } from '@seed/validators' -import { AgGridAngular } from 'ag-grid-angular' -import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { InventoryDisplayType, InventoryType } from 'app/modules/inventory/inventory.types' -import { Subject, switchMap, take, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-inventory-groups-modal', diff --git a/src/app/modules/inventory/actions/labels-modal.component.ts b/src/app/modules/inventory/actions/labels-modal.component.ts index d614f12f..7d0824ea 100644 --- a/src/app/modules/inventory/actions/labels-modal.component.ts +++ b/src/app/modules/inventory/actions/labels-modal.component.ts @@ -1,18 +1,18 @@ import { CommonModule } from '@angular/common' -import type { OnDestroy, OnInit} from '@angular/core' +import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import type { Label, LabelColor} from '@seed/api' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellValueChangedEvent, ColDef } from 'ag-grid-community' +import { Subject, switchMap, takeUntil, tap } from 'rxjs' +import type { Label, LabelColor } from '@seed/api' import { LabelService } from '@seed/api' import { ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' -import { AgGridAngular } from 'ag-grid-angular' -import { Subject, switchMap, takeUntil, tap } from 'rxjs' -import { InventoryType } from '../inventory.types' -import { CellValueChangedEvent, ColDef } from 'ag-grid-community' import { ConfigService } from '@seed/services' import { SEEDValidators } from '@seed/validators' +import type { InventoryType } from '../inventory.types' @Component({ selector: 'seed-labels-modal', diff --git a/src/styles/styles.scss b/src/styles/styles.scss index c4ff4d7e..d9660949 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -3,7 +3,7 @@ // colors for text alerts :root { - --primary-text-color: rgb(29, 78, 216); + --primary-text-color: rgb(29 78 216); --success-text-color: rgb(21 128 61); --info-text-color: theme('colors.cyan.600'); --warning-text-color: rgb(180 83 9); @@ -241,14 +241,14 @@ .border-button-toggle-group { @apply rounded-full w-fit gap-0 !important; - border: 1px solid rgb(170, 170, 170) !important; + border: 1px solid rgb(170 170 170) !important; .mat-button-toggle { @apply rounded-none m-0 !important; } .mat-button-toggle:not(:first-child) { - border-left: 1px solid rgb(170, 170, 170) !important; + border-left: 1px solid rgb(170 170 170) !important; } // Uncomment to change checked button to primary color From 1e1b1ecb7142c3bbf2f435f7a6ea87261ac962e8 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 29 Jul 2025 17:50:15 +0000 Subject: [PATCH 16/30] lint --- .../list/actions/refresh-metadata-modal.component.html | 1 - src/app/modules/inventory/actions/export-modal.component.html | 2 -- src/styles/styles.scss | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html index c847261c..23444d78 100644 --- a/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/refresh-metadata-modal.component.html @@ -2,7 +2,6 @@ @if (!inProgress) {
This will set the selected inventory's 'Updated' timestamp to {{ currentTime }}.
- } @else { Include Meter Readings (Only recommended for small exports) - } @else if (form.value.export_type === 'csv') { Include Label Header - } @else {
} diff --git a/src/styles/styles.scss b/src/styles/styles.scss index d9660949..7d8f6a06 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -241,6 +241,7 @@ .border-button-toggle-group { @apply rounded-full w-fit gap-0 !important; + border: 1px solid rgb(170 170 170) !important; .mat-button-toggle { From cd914c6d92ecb9cdb39f848027a8024d200cab4b Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 29 Jul 2025 18:43:16 +0000 Subject: [PATCH 17/30] dev --- .../list/actions/geocode-modal.component.html | 12 +++++++++++- .../list/actions/geocode-modal.component.ts | 7 +++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html index 225952bf..ad92764d 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -23,7 +23,17 @@
{{ t('SUGGEST_TO_VERIFY') }}
} --> -
diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index 992204b2..e2a68e97 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -3,7 +3,7 @@ import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { forkJoin, Subject, tap } from 'rxjs' import { GeocodeService } from '@seed/api' -import type { ConfidenceSummary, GeocodingColumns } from '@seed/api/geocode/geocode.types' +import type { ConfidenceSummary, GeocodingColumns, InventoryConfidenceSummary } from '@seed/api/geocode/geocode.types' import { AlertComponent, ModalHeaderComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' @@ -26,6 +26,8 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { hasGeoColumns = true suggestVerify = true notGeocoded = false + pSummary: InventoryConfidenceSummary + tSummary: InventoryConfidenceSummary geocodeState: 'verify' | 'geocode' | 'result' | 'fail' = 'verify' @@ -66,7 +68,8 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { processConfidenceSummary(confidenceSummary: ConfidenceSummary) { this.confidenceSummary = confidenceSummary - // Process the confidence summary as needed + this.pSummary = confidenceSummary.properties + this.tSummary = confidenceSummary.taxlots } close(success = false) { From 37fbbb079c280cf443d603db60a689041e935841 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 30 Jul 2025 13:59:31 +0000 Subject: [PATCH 18/30] geocode modal fxnal --- src/@seed/api/geocode/geocode.service.ts | 2 +- src/@seed/api/geocode/geocode.types.ts | 4 +- src/@seed/services/error/error.service.ts | 7 +- .../list/actions/geocode-modal.component.html | 103 +++++++++++++++--- .../list/actions/geocode-modal.component.ts | 59 ++++++++-- 5 files changed, 143 insertions(+), 32 deletions(-) diff --git a/src/@seed/api/geocode/geocode.service.ts b/src/@seed/api/geocode/geocode.service.ts index 5e3fc35e..1b8ce4dc 100644 --- a/src/@seed/api/geocode/geocode.service.ts +++ b/src/@seed/api/geocode/geocode.service.ts @@ -13,7 +13,7 @@ export class GeocodeService { private _errorService = inject(ErrorService) geocode(orgId: number, viewIds: number[], type: InventoryType): Observable { - const url = `/api/v3/geocode/geocode_by_ids/&organization_id=${orgId}` + const url = `/api/v3/geocode/geocode_by_ids/organization_id=${orgId}` const data = { property_view_ids: type === 'taxlots' ? [] : viewIds, taxlot_view_ids: type === 'taxlots' ? viewIds : [], diff --git a/src/@seed/api/geocode/geocode.types.ts b/src/@seed/api/geocode/geocode.types.ts index a9c3206b..6c34d1d5 100644 --- a/src/@seed/api/geocode/geocode.types.ts +++ b/src/@seed/api/geocode/geocode.types.ts @@ -1,6 +1,6 @@ export type ConfidenceSummary = { - properties: InventoryConfidenceSummary; - taxlots: InventoryConfidenceSummary; + properties?: InventoryConfidenceSummary; + taxlots?: InventoryConfidenceSummary; } export type InventoryConfidenceSummary = { diff --git a/src/@seed/services/error/error.service.ts b/src/@seed/services/error/error.service.ts index 25fe0e73..ec678661 100644 --- a/src/@seed/services/error/error.service.ts +++ b/src/@seed/services/error/error.service.ts @@ -25,11 +25,14 @@ export class ErrorService { getErrorData(error: HttpErrorResponse, defaultMessage: string) { // Handle different error response structures const err: unknown = error.error + const status = error.status ? `${error.status}: ` : '' const isStr = typeof err === 'string' // If the string is too long (likely html '...'), return the default message - if (isStr && (err.length > 1000 || err.startsWith(''))) return defaultMessage + if (isStr && (err.length > 1000 || err.startsWith(''))) { + return `${status}${defaultMessage}` + } if (isStr) return err const isObj = typeof err === 'object' && err !== null @@ -38,7 +41,7 @@ export class ErrorService { return e.message ?? e.error ?? e.errors ?? null } - return defaultMessage + return `${status}${defaultMessage}` } isObjOfArrayStrings(obj: unknown): obj is Record { diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html index ad92764d..f595b8f2 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -2,42 +2,111 @@
+ + @if (geocodeState === 'verify') { + + @if (!hasGeoColumns) { + +
+ {{ t('GEOCODING_COL_COUNT_REQUIREMENT') }} + {{ t('SUGGEST_UPDATE_GEOCODE_COLS') }} +
+
+ } + @if (!hasApiKey) { - -
- {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} -
-
- {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }} + +
+ {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} + {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }}
} @if (!geocodingEnabled) { - + {{ t('Geocoding has been disabled for this organization.') }} } + @if (pSummary?.not_geocoded || tSummary?.not_geocoded) { + + {{ t('NOT_GEOCODED_PREVIOUSLY') }} + @if (pSummary?.not_geocoded) {
  • {{ pSummary?.not_geocoded }} properties
  • } + @if (tSummary?.not_geocoded) {
  • {{ tSummary?.not_geocoded }} tax lots
  • } +
    + } + +} + + - @if (pSummary?.not_geocoded || tSummary?.not_geocoded || true) { - - {{ t('NOT_GEOCODED_PREVIOUSLY') }} - @if (pSummary?.not_geocoded) {
  • {{ pSummary?.not_geocoded }} properties
  • } - @if (tSummary?.not_geocoded) {
  • {{ tSummary?.not_geocoded }} tax lots
  • } + + + + @if ((pMessages || tMessages)) { + + @if (geocodeState === 'verify') { +
    + {{ t('GEOCODE_ATTEMPTED_PREVIOUSLY') }} + {{ t('UPDATE_MANUALLY_GEOCODED_INSTRUCTIONS') }} +
    + } + @if (geocodeState === 'result') { +
    {{ t('POST_GEOCODING_COUNTS')}}
    + } + + + @if (pMessages) {
    Properties
    } + @if (pSummary?.high_confidence) { +
  • {{ pSummary?.high_confidence }} {{ t('GEOCODED_WITH_HIGH_CONFIDENCE') }}
  • + } + @if (pSummary?.low_confidence) { +
  • {{ pSummary?.low_confidence }} {{ t('GEOCODED_WITH_LOW_CONFIDENCE') }}
  • + } + @if (pSummary?.manual) { +
  • {{ pSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} + @if (geocodeState === 'verify') { - {{ t('ITEMS_WILL_NOT_CHANGE') }}} +
  • + } + @if (pSummary?.missing_address_components) { +
  • {{ pSummary?.missing_address_components }} {{ t('GEOCODE_UNSUCCESSFUL_MISSING_FIELDS') }}
  • + } + + + @if (tMessages) {
    Tax Lots
    } + @if (tSummary?.high_confidence) { +
  • {{ tSummary?.high_confidence }} {{ t('GEOCODED_WITH_HIGH_CONFIDENCE') }}
  • + } + @if (tSummary?.low_confidence) { +
  • {{ tSummary?.low_confidence }} {{ t('GEOCODED_WITH_LOW_CONFIDENCE') }}
  • + } + @if (tSummary?.manual) { +
  • {{ tSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} + @if (geocodeState === 'verify') { - {{ t('ITEMS_WILL_NOT_CHANGE') }}} +
  • + } + @if (tSummary?.missing_address_components) { +
  • {{ tSummary?.missing_address_components }} {{ t('GEOCODE_UNSUCCESSFUL_MISSING_FIELDS') }}
  • + } +
    + } + + @if (geocodeState === 'fail') { + + {{ errorMessage }} } -
    - +
    diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index e2a68e97..52de37ae 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -1,26 +1,31 @@ +import type { HttpErrorResponse } from '@angular/common/http' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import { forkJoin, Subject, tap } from 'rxjs' import { GeocodeService } from '@seed/api' import type { ConfidenceSummary, GeocodingColumns, InventoryConfidenceSummary } from '@seed/api/geocode/geocode.types' -import { AlertComponent, ModalHeaderComponent } from '@seed/components' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' import type { InventoryType } from 'app/modules/inventory/inventory.types' +import { catchError, EMPTY, forkJoin, Subject, switchMap, tap } from 'rxjs' @Component({ selector: 'seed-geocode-modal', templateUrl: './geocode-modal.component.html', - imports: [AlertComponent, MaterialImports, ModalHeaderComponent, SharedImports], + imports: [AlertComponent, MaterialImports, ModalHeaderComponent, ProgressBarComponent, SharedImports], }) export class GeocodeModalComponent implements OnInit, OnDestroy { private _dialogRef = inject(MatDialogRef) private _geocodeService = inject(GeocodeService) private _unsubscribeAll$ = new Subject() - confidenceSummary: ConfidenceSummary geocodingEnabled = true + geoColumns: GeocodingColumns = { + PropertyState: [], + TaxLotState: [], + } + confidenceSummary: ConfidenceSummary = {} hasApiKey = true hasEnoughGeoCols = true hasGeoColumns = true @@ -28,8 +33,20 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { notGeocoded = false pSummary: InventoryConfidenceSummary tSummary: InventoryConfidenceSummary + pMessages: boolean + tMessages: boolean + pNotGeocoded: boolean + tNotGeocoded: boolean + errorMessage: string + + geocodeState: 'verify' | 'geocoding' | 'result' | 'fail' = 'verify' - geocodeState: 'verify' | 'geocode' | 'result' | 'fail' = 'verify' + typeMap = { + verify: 'warning', + geocode: 'primary', + result: 'success', + fail: 'warn', + } data = inject(MAT_DIALOG_DATA) as { orgId: number; @@ -67,17 +84,39 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { } processConfidenceSummary(confidenceSummary: ConfidenceSummary) { - this.confidenceSummary = confidenceSummary - this.pSummary = confidenceSummary.properties - this.tSummary = confidenceSummary.taxlots + const { properties, taxlots } = confidenceSummary + this.pSummary = properties + this.tSummary = taxlots + + this.pMessages = !!properties && !!(properties.high_confidence || properties.low_confidence || properties.manual || properties.missing_address_components) + this.tMessages = !!taxlots && !!(taxlots.high_confidence || taxlots.low_confidence || taxlots.manual || taxlots.missing_address_components) } close(success = false) { this._dialogRef.close(success) } - onSubmit() { - console.log('submit') + geocodeBuildings() { + this.geocodeState = 'geocoding' + this.processGeoColumns({ PropertyState: [], TaxLotState: [] }) // reset columns + this.processConfidenceSummary({}) // reset confidence summary + + const { orgId, viewIds, type } = this.data + this._geocodeService.geocode(orgId, viewIds, type) + .pipe( + switchMap(() => this._geocodeService.confidenceSummary(this.data.orgId, this.data.viewIds, this.data.type)), + tap((confidenceSummary) => { + this.processConfidenceSummary(confidenceSummary) + this.geocodeState = 'result' + }), + catchError((error: HttpErrorResponse) => { + const defaultMessage = 'An error occurred while geocoding.' + this.geocodeState = 'fail' + this.errorMessage = error.message ?? defaultMessage + return EMPTY + }), + ) + .subscribe() } ngOnDestroy(): void { From a871898f81f693f5b62ef2e4fcc3d89badb2c969 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 30 Jul 2025 15:45:45 +0000 Subject: [PATCH 19/30] valid status --- .../inventory-list/list/actions/geocode-modal.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index 52de37ae..862375d9 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -55,7 +55,7 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { } get valid() { - return this.hasApiKey && this.geocodingEnabled && this.hasGeoColumns + return this.geocodeState === 'verify' && this.hasApiKey && this.geocodingEnabled && this.hasGeoColumns } ngOnInit(): void { From 4400d2403cdfc71633f5a551172875e0ff2c168b Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 8 Aug 2025 15:43:10 +0000 Subject: [PATCH 20/30] geocode and merge fxnal --- src/@seed/api/geocode/geocode.service.ts | 2 +- src/@seed/api/index.ts | 1 + src/@seed/api/inventory/inventory.service.ts | 9 + src/@seed/api/matching/index.ts | 2 + src/@seed/api/matching/matching.service.ts | 28 ++++ src/@seed/api/matching/matching.types.ts | 6 + .../api/organization/organization.types.ts | 12 ++ src/@seed/api/user/user.service.ts | 4 + .../list/actions/geocode-modal.component.html | 6 +- .../list/actions/geocode-modal.component.ts | 2 +- .../inventory-list/list/actions/index.ts | 1 + .../list/actions/merge-modal.component.html | 102 ++++++++++++ .../list/actions/merge-modal.component.ts | 156 ++++++++++++++++++ 13 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 src/@seed/api/matching/index.ts create mode 100644 src/@seed/api/matching/matching.service.ts create mode 100644 src/@seed/api/matching/matching.types.ts create mode 100644 src/app/modules/inventory-list/list/actions/merge-modal.component.html create mode 100644 src/app/modules/inventory-list/list/actions/merge-modal.component.ts diff --git a/src/@seed/api/geocode/geocode.service.ts b/src/@seed/api/geocode/geocode.service.ts index 1b8ce4dc..13490c23 100644 --- a/src/@seed/api/geocode/geocode.service.ts +++ b/src/@seed/api/geocode/geocode.service.ts @@ -13,7 +13,7 @@ export class GeocodeService { private _errorService = inject(ErrorService) geocode(orgId: number, viewIds: number[], type: InventoryType): Observable { - const url = `/api/v3/geocode/geocode_by_ids/organization_id=${orgId}` + const url = `/api/v3/geocode/geocode_by_ids/?organization_id=${orgId}` const data = { property_view_ids: type === 'taxlots' ? [] : viewIds, taxlot_view_ids: type === 'taxlots' ? viewIds : [], diff --git a/src/@seed/api/index.ts b/src/@seed/api/index.ts index a7a17037..54ea4207 100644 --- a/src/@seed/api/index.ts +++ b/src/@seed/api/index.ts @@ -12,6 +12,7 @@ export * from './groups' export * from './inventory' export * from './label' export * from './mapping' +export * from './matching' export * from './meters' export * from './notes' export * from './organization' diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index 35733f00..31bd9e87 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -397,4 +397,13 @@ export class InventoryService { }), ) } + + propertiesMetersExist(orgId: number, propertyViewIds: number[]): Observable { + const url = `/api/v3/properties/meters_exist/?organization_id=${orgId}` + return this._httpClient.post(url, { property_view_ids: propertyViewIds }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error checking if meters exist for properties') + }), + ) + } } diff --git a/src/@seed/api/matching/index.ts b/src/@seed/api/matching/index.ts new file mode 100644 index 00000000..04ba1b9e --- /dev/null +++ b/src/@seed/api/matching/index.ts @@ -0,0 +1,2 @@ +export * from './matching.service' +export * from './matching.types' diff --git a/src/@seed/api/matching/matching.service.ts b/src/@seed/api/matching/matching.service.ts new file mode 100644 index 00000000..2047622b --- /dev/null +++ b/src/@seed/api/matching/matching.service.ts @@ -0,0 +1,28 @@ +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable} from 'rxjs'; +import { catchError, 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' +import type { MergingResponse } from './matching.types' + +@Injectable({ providedIn: 'root' }) +export class MatchingService { + private _httpClient = inject(HttpClient) + private _errorService = inject(ErrorService) + private _snackBar = inject(SnackBarService) + + mergeInventory(orgId: number, viewIds: number[], type: InventoryType): Observable { + const url = `/api/v3/${type}/merge/?organization_id=${orgId}` + const key = type === 'taxlots' ? 'taxlot_view_ids' : 'property_view_ids' + const data = { [key]: viewIds } + return this._httpClient.post(url, data) + .pipe( + tap(() => { this._snackBar.success('Successfully merged inventory') }), + catchError(() => { + return this._errorService.handleError(null, 'Error merging inventory') + }), + ) + } +} diff --git a/src/@seed/api/matching/matching.types.ts b/src/@seed/api/matching/matching.types.ts new file mode 100644 index 00000000..535b0cb8 --- /dev/null +++ b/src/@seed/api/matching/matching.types.ts @@ -0,0 +1,6 @@ +export type MergingResponse = { + status: string; + match_link_count?: number; + match_merged_count?: number; + message?: string; +} diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index 9de0b73f..93785d20 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -107,6 +107,7 @@ export type OrganizationUserSettings = { profile?: UserSettingsProfiles; crossCycles?: UserSettingsCrossCycles; labels?: UserLabelSettings; + pins?: UserPinSettings; } type UserSettingsFilters = { @@ -129,6 +130,17 @@ type UserSettingsCrossCycles = { taxlots?: number[]; } +type UserPinSettings = { + properties?: { + left: string[]; + right: string[]; + }; + taxlots?: { + left: string[]; + right: string[]; + }; +} + type UserLabelSettings = { ids: number[]; operator: LabelOperator } export type OrganizationUsersResponse = { diff --git a/src/@seed/api/user/user.service.ts b/src/@seed/api/user/user.service.ts index 38314b6a..eb8d3360 100644 --- a/src/@seed/api/user/user.service.ts +++ b/src/@seed/api/user/user.service.ts @@ -194,5 +194,9 @@ export class UserService { userSettings.sorts ??= {} userSettings.sorts.properties ??= [] userSettings.sorts.taxlots ??= [] + + userSettings.pins ??= {} + userSettings.pins.properties ??= { left: [], right: [] } + userSettings.pins.taxlots ??= { left: [], right: [] } } } diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html index f595b8f2..8bcc1ee6 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -108,5 +108,9 @@
    - + @if (geocodeState === 'verify') { + + } @else if (['result', 'fail'].includes(geocodeState)) { + + }
    diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index 862375d9..98ea2e2e 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -2,13 +2,13 @@ import type { HttpErrorResponse } from '@angular/common/http' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { catchError, EMPTY, forkJoin, Subject, switchMap, tap } from 'rxjs' import { GeocodeService } from '@seed/api' import type { ConfidenceSummary, GeocodingColumns, InventoryConfidenceSummary } from '@seed/api/geocode/geocode.types' import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' import type { InventoryType } from 'app/modules/inventory/inventory.types' -import { catchError, EMPTY, forkJoin, Subject, switchMap, tap } from 'rxjs' @Component({ selector: 'seed-geocode-modal', diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index 53534b5c..34e627cd 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,3 +1,4 @@ export * from './geocode-modal.component' +export * from './merge-modal.component' export * from './refresh-metadata-modal.component' export * from './update-derived-data.component' diff --git a/src/app/modules/inventory-list/list/actions/merge-modal.component.html b/src/app/modules/inventory-list/list/actions/merge-modal.component.html new file mode 100644 index 00000000..590f342c --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/merge-modal.component.html @@ -0,0 +1,102 @@ + + +
    + @if (status === 'loading') { + + } + + @else if (status === 'review') { +
    +
    + + Resulting Merge +
    + + + + + +
    + + Selected Inventory +
    +
    + Records will be merged together from bottom to top, with the top record having the highest priority. Drag to reorder. +
    + + + + +
    + } + + @else if (status === 'confirm') { + This action cannot be undone + +
    + + +
    + +
      +
    • {{ t('MERGE_IN_CYCLE_MATCHES') }}
    • +
    • {{ t('LINK_OUT_CYCLE_MATCHES') }}
    • +
    • {{ t('MERGE_MULTIPLE_IN_CYCLE_MATCHES') }}
    • +
    + + +
    {{ t('LISTING_ORG_MATCHING_CRITERIA') }}
    +
      + @for (column of matchingColumnDisplayNames; track $index) {
    • {{ column }}
    • } +
    +
    +
    + + +
    + + } @else if (status === 'complete') { +
    Merge completed successfully!
    + +
      + @for (result of results; track $index) {
    • {{ result }}
    • } +
    +
    + + } @else if (status === 'error') { + {{ errorMessage }} + } + + +
    + +
    + @if (status === 'review') { + + } + @if (status === 'confirm') { + + } + @if (status === 'complete' || status === 'error') { + + } +
    \ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/merge-modal.component.ts b/src/app/modules/inventory-list/list/actions/merge-modal.component.ts new file mode 100644 index 00000000..4e6e9087 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/merge-modal.component.ts @@ -0,0 +1,156 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { Column, InventoryService, MatchingService, MappableColumnService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import { AgGridAngular } from 'ag-grid-angular' +import { ColDef, GridApi, GridOptions, GridReadyEvent, RowDragEndEvent, RowSelectedEvent } from 'ag-grid-community' +import type { FilterResponse, InventoryType, State } from 'app/modules/inventory/inventory.types' +import { catchError, combineLatest, EMPTY, finalize, Observable, Subject, take, tap } from 'rxjs' + +@Component({ + selector: 'seed-merge-modal', + templateUrl: './merge-modal.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + SharedImports, + ], +}) +export class MergeModalComponent implements OnInit, OnDestroy { + private _configService = inject(ConfigService) + private _dialogRef = inject(MatDialogRef) + private _matchingService = inject(MatchingService) + private _mappableColumnService = inject(MappableColumnService) + private _inventoryService = inject(InventoryService) + private _unsubscribeAll$ = new Subject() + status: 'loading' | 'review' | 'confirm' | 'complete' | 'error' = 'loading' + columns: Column[] = [] + inventory: FilterResponse + metersExist = false + loading: boolean + gridTheme$ = this._configService.gridTheme$ + preGridApi: GridApi + postData: Record[] = [] + colDefs: ColDef[] = [] + preData: State[] = [] + gridOptions: GridOptions = { rowDragManaged: true } + gridHeight = 400 + title = 'Merge Inventory' + results: string[] = [] + errorMessage: string = null + matchingColumnDisplayNames: string[] = [] + + data = inject(MAT_DIALOG_DATA) as { + cycleId: number; + orgId: number; + profileId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + const { orgId, viewIds } = this.data + const metersExist$ = this._inventoryService.propertiesMetersExist(orgId, viewIds) + const columns$ = this.data.type === 'taxlots' ? this._mappableColumnService.getTaxLotColumns(orgId) : this._mappableColumnService.getPropertyColumns(orgId) + const inventory$ = this.getInventory$() + + combineLatest([metersExist$, columns$, inventory$]) + .pipe( + take(1), + tap(([metersExist, columns, inventory]) => { + this.metersExist = metersExist + this.columns = columns + this.matchingColumnDisplayNames = this.columns.filter((c) => c.is_matching_criteria).map((c) => c.display_name) + this.inventory = inventory + this.setGrid() + this.status = 'review' + }), + ) + .subscribe() + } + + getInventory$(): Observable { + const { cycleId, orgId, profileId, viewIds } = this.data + const inventory_type = this.data.type === 'taxlots' ? 'taxlot' : 'property' + const params = new URLSearchParams({ + cycle: cycleId.toString(), + ids_only: 'false', + include_related: 'true', + inventory_type, + organization_id: orgId.toString(), + page: '1', + per_page: '999999999', + }) + + const data = { + include_property_ids: null, + profile_id: profileId, + include_view_ids: viewIds, + } + + return this._inventoryService.getAgInventory(params.toString(), data) + } + + setGrid() { + this.preData = this.inventory.results as State[] + this.gridHeight = Math.min(this.preData.length * 35 + 43, 500) + const dragRow: ColDef = { field: 'Drag', rowDrag: true, resizable: false, width: 70, pinned: 'left' } + this.colDefs = [dragRow, ...this.inventory.column_defs] + this.postData = [this.preData[0]] + } + + onPreGridReady(agGrid: GridReadyEvent) { + this.preGridApi = agGrid.api + } + + onRowDragEnd() { + const firstRow = this.preGridApi.getDisplayedRowAtIndex(0) + this.postData = [firstRow.data] as State[] + } + + onSubmit() { + this.status = 'confirm' + this.title = 'Are you sure you want to continue?' + console.log('are you sure') + } + + onConfirm() { + const { orgId, viewIds, type } = this.data + const singularType = type === 'taxlots' ? 'tax lot' : 'property' + this._matchingService.mergeInventory(orgId, viewIds, type) + .pipe( + tap(({ match_link_count, match_merged_count }) => { + this.results = [ + `Resulting ${singularType} has ${match_link_count} cross cycle links`, + `${match_merged_count} subsequent ${type} merged`, + ] + this.status = 'complete' + this.title = 'Merge Complete' + }), + catchError(({ message }) => { + this.errorMessage = message as string + this.status = 'error' + return EMPTY + }), + ) + .subscribe() + } + + close(success = false): void { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} From 995c3cd11d3f79e62a424445dfa6199020f63300 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 8 Aug 2025 17:39:15 +0000 Subject: [PATCH 21/30] pinning --- .../datasets/dataset/dataset.component.ts | 2 ++ .../grid/building-files-grid.component.ts | 6 +--- .../list/grid/actions.component.html | 10 ++----- .../list/grid/actions.component.ts | 25 +++++++++++----- .../list/grid/cell-header-menu.component.ts | 22 ++++++++++++++ .../list/grid/grid-controls.component.ts | 11 +++++-- .../list/grid/grid.component.ts | 2 +- .../list/inventory.component.ts | 29 +++++++++++++++++++ 8 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 711cc667..6da34273 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -156,6 +156,8 @@ export class DatasetComponent implements OnDestroy, OnInit { downloadDocument(file: string, filename: string) { const a = document.createElement('a') + // NOTE: downloads failing after a recent change. Requires further investigation + // const url = file.replace('/seed/', '/') const url = file a.href = url a.download = filename diff --git a/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts index 8ace9052..e68ebb0d 100644 --- a/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts @@ -39,10 +39,7 @@ export class BuildingFilesGridComponent implements OnInit { setColumnDefs() { this.columnDefs = [ { field: 'file_type', headerName: 'File Type' }, - { - field: 'filename', - headerName: 'File Name', - }, + { field: 'filename', headerName: 'File Name' }, { field: 'created', headerName: 'Created' }, { field: 'actions', headerName: 'Actions', cellRenderer: this.actionRenderer }, ] @@ -71,7 +68,6 @@ export class BuildingFilesGridComponent implements OnInit { downloadDocument(data: unknown) { const { file, filename } = data as { file: string; filename: string } - console.log('Developer Note: Downloads will fail until frontend and backend are on the same server') const a = document.createElement('a') const url = file a.href = url diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 7efe75a4..5f74b53c 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -15,15 +15,9 @@ - - + + Access Levels 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 2f9aa687..ce1196b4 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -11,7 +11,7 @@ import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.comp import { DQCStartModalComponent } from 'app/modules/data-quality' import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { GeocodeModalComponent, RefreshMetadataModalComponent, UpdateDerivedDataComponent } from '../actions' +import { GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UpdateDerivedDataComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', @@ -102,6 +102,22 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { .subscribe() } + openExportModal() { + const dialogRef = this._dialog.open(ExportModalComponent, { + width: '40rem', + data: { ...this.baseData(), profileId: this.profile?.id || null }, + }) + this.afterClosed(dialogRef) + } + + openMergeModal() { + const dialogRef = this._dialog.open(MergeModalComponent, { + width: '50rem', + data: { ...this.baseData(), cycleId: this.cycleId, profileId: this.profile?.id || null }, + }) + this.afterClosed(dialogRef) + } + openShowPopulatedColumnsModal() { const dialogRef = this._dialog.open(ModalComponent, { width: '40rem', @@ -121,13 +137,6 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } - openExportModal() { - const dialogRef = this._dialog.open(ExportModalComponent, { - width: '40rem', - data: { ...this.baseData(), profileId: this.profile?.id || null }, - }) - this.afterClosed(dialogRef) - } openAliChangeModal() { const dialogRef = this._dialog.open(AliChangeModalComponent, { diff --git a/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts b/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts index 53d3dede..95a355b8 100644 --- a/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts +++ b/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts @@ -54,6 +54,7 @@ export class CellHeaderMenuComponent implements IHeaderAngularComp, AfterViewIni this.getScheme() this.setOverlay() this.updateSortState() + this.pinState = this.column.isPinned() this.column.addEventListener('sortChanged', () => { this.updateSortState() }) @@ -129,6 +130,27 @@ export class CellHeaderMenuComponent implements IHeaderAngularComp, AfterViewIni pinCol(direction: 'left' | 'right' | null): void { this.gridApi.setColumnsPinned([this.column], direction) this.detach() + this.updatePins(direction) + } + + updatePins(direction: 'left' | 'right'): void { + const field = this.column.getColDef().field + const pins = this.userSettings.pins[this.type] + const left = new Set(pins.left) + const right = new Set(pins.right) + + // Clear from both sides + left.delete(field) + right.delete(field) + + if (direction === 'left') left.add(field) + if (direction === 'right') right.add(field) + + // Assign back as arrays + pins.left = Array.from(left) + pins.right = Array.from(right) + + this.updateOrgUserSettings() } hideCol() { diff --git a/src/app/modules/inventory-list/list/grid/grid-controls.component.ts b/src/app/modules/inventory-list/list/grid/grid-controls.component.ts index 729de61b..7abbffc2 100644 --- a/src/app/modules/inventory-list/list/grid/grid-controls.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid-controls.component.ts @@ -49,8 +49,10 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { resetGrid() { this.resetColumns() + this.resetPins() this.resetFilters() this.resetSorts() + this.updateOrgUser() } resetColumns() { @@ -62,7 +64,6 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { this.userSettings.filters = this.currentUser.settings.filters ?? {} this.userSettings.filters.properties = {} this.userSettings.filters.taxlots = {} - this.updateOrgUser() } resetSorts() { @@ -71,7 +72,13 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { this.userSettings.sorts = this.currentUser.settings?.sorts ?? {} this.userSettings.sorts.properties = [] this.userSettings.sorts.taxlots = [] - this.updateOrgUser() + } + + resetPins() { + this.userSettings.pins = this.currentUser.settings?.pins ?? {} + this.userSettings.pins.properties = { left: [], right: [] } + this.userSettings.pins.taxlots = { left: [], right: [] } + this.gridApi.applyColumnState({ defaultState: { pinned: null } }) } updateOrgUser() { diff --git a/src/app/modules/inventory-list/list/grid/grid.component.ts b/src/app/modules/inventory-list/list/grid/grid.component.ts index e119b380..a8f157cc 100644 --- a/src/app/modules/inventory-list/list/grid/grid.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid.component.ts @@ -120,7 +120,7 @@ export class InventoryGridComponent implements OnChanges { getShortcutColumns(): ColDef[] { const shortcutColumns = [ this.buildInfoCell(), - this.buildShortcutColumn('merged_indicator', 'Merged', 82, 'share'), + this.buildShortcutColumn('merged_indicator', 'Merged', 82, 'merge'), this.buildShortcutColumn('meters_exist_indicator', 'Meters', 78, 'bolt', 'meters'), this.buildShortcutColumn('notes_count', 'Notes', 71, 'mode_comment', 'notes'), this.buildShortcutColumn('groups_indicator', 'Groups', 79, 'G'), diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 81d1e512..fd725481 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -207,6 +207,7 @@ export class InventoryComponent implements OnDestroy, OnInit { * returns a null observable to track completion */ loadInventory(): Observable { + this.validateCycleId() if (!this.cycleId) return of(null) const inventory_type = this.type === 'properties' ? 'property' : 'taxlot' const params = new URLSearchParams({ @@ -238,12 +239,20 @@ export class InventoryComponent implements OnDestroy, OnInit { ) as Observable } + validateCycleId() { + if (!this.cycleId) this.cycleId = null + const settingsCycleId = this.userSettings?.cycleId + if (!settingsCycleId) return + if (settingsCycleId !== this.cycleId) this.cycleId = settingsCycleId + } + /* * on initial page load, set any filters and sorts from the user settings */ setFilterSorts() { this.setFilters() this.setSorts() + this.setPins() } onGridReady(gridApi: GridApi) { @@ -306,6 +315,26 @@ export class InventoryComponent implements OnDestroy, OnInit { this.gridApi.onSortChanged() } + setPins() { + if (!this.userSettings.pins) return + + const { left, right } = this.userSettings.pins[this.type] || {} + + for (const col of left) { + const colDef = this.columnDefs.find((c) => c.field === col) + if (colDef) { + colDef.pinned = 'left' + } + } + + for (const col of right) { + const colDef = this.columnDefs.find((c) => c.field === col) + if (colDef) { + colDef.pinned = 'right' + } + } + } + get sorts() { return this.userSettings.sorts?.[this.type] ?? [] } From 575a8ddabf4bd602eb4fdafd0737fa403711f620 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 8 Aug 2025 18:27:12 +0000 Subject: [PATCH 22/30] track settings$ --- src/@seed/api/organization/organization.service.ts | 4 ++++ .../list/grid/grid-controls.component.ts | 11 +++-------- .../inventory-list/list/inventory.component.ts | 7 +++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/@seed/api/organization/organization.service.ts b/src/@seed/api/organization/organization.service.ts index 11ec334f..84f05c60 100644 --- a/src/@seed/api/organization/organization.service.ts +++ b/src/@seed/api/organization/organization.service.ts @@ -28,6 +28,7 @@ import type { OrganizationsResponse, OrganizationUser, OrganizationUserResponse, + OrganizationUserSettings, OrganizationUsersResponse, StartSavingAccessLevelInstancesRequest, UpdateAccessLevelsRequest, @@ -44,6 +45,7 @@ export class OrganizationService { private _organizations = new ReplaySubject(1) private _currentOrganization = new ReplaySubject(1) + private _orgUserSettings = new ReplaySubject(1) private _organizationUsers = new ReplaySubject(1) private _accessLevelTree = new ReplaySubject(1) private _accessLevelInstancesByDepth = new ReplaySubject(1) @@ -51,6 +53,7 @@ export class OrganizationService { organizations$ = this._organizations.asObservable() currentOrganization$ = this._currentOrganization.asObservable() + orgUserSettings$ = this._orgUserSettings.asObservable() organizationUsers$ = this._organizationUsers.asObservable() accessLevelTree$ = this._accessLevelTree.asObservable() accessLevelInstancesByDepth$ = this._accessLevelInstancesByDepth.asObservable() @@ -113,6 +116,7 @@ export class OrganizationService { const data = { settings } const url = `/api/v4/organization_users/${orgUserId}/?organization_id=${orgId}` return this._httpClient.put(url, data).pipe( + tap(({ data }) => { this._orgUserSettings.next(data.settings) }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating organization user') }), diff --git a/src/app/modules/inventory-list/list/grid/grid-controls.component.ts b/src/app/modules/inventory-list/list/grid/grid-controls.component.ts index 7abbffc2..1d7d416e 100644 --- a/src/app/modules/inventory-list/list/grid/grid-controls.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid-controls.component.ts @@ -48,17 +48,12 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { } resetGrid() { - this.resetColumns() this.resetPins() this.resetFilters() this.resetSorts() this.updateOrgUser() } - resetColumns() { - this.gridApi.autoSizeAllColumns() - } - resetFilters() { this.gridApi.setFilterModel(null) this.userSettings.filters = this.currentUser.settings.filters ?? {} @@ -67,8 +62,6 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { } resetSorts() { - this.gridApi.applyColumnState({ state: [], applyOrder: true }) - this.gridApi.resetColumnState() this.userSettings.sorts = this.currentUser.settings?.sorts ?? {} this.userSettings.sorts.properties = [] this.userSettings.sorts.taxlots = [] @@ -78,7 +71,6 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { this.userSettings.pins = this.currentUser.settings?.pins ?? {} this.userSettings.pins.properties = { left: [], right: [] } this.userSettings.pins.taxlots = { left: [], right: [] } - this.gridApi.applyColumnState({ defaultState: { pinned: null } }) } updateOrgUser() { @@ -87,9 +79,12 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { .pipe( take(1), tap(() => { + this.gridApi.resetColumnState() + this.gridApi.applyColumnState({ defaultState: { pinned: null } }) this.gridApi.refreshClientSideRowModel() this.gridApi.refreshCells({ force: true }) this.gridApi.onSortChanged() + this.gridApi.autoSizeAllColumns() }), ) .subscribe() diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index fd725481..2a07f46a 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -123,6 +123,12 @@ export class InventoryComponent implements OnDestroy, OnInit { ) .subscribe() + this._organizationService.orgUserSettings$ + .pipe( + tap((settings) => this.userSettings = settings), + ) + .subscribe() + this.refreshInventory$.pipe(switchMap(() => this.refreshInventory())).subscribe() } @@ -347,6 +353,7 @@ export class InventoryComponent implements OnDestroy, OnInit { this.page = 1 this.userSettings.filters[this.type] = filters this.userSettings.sorts[this.type] = sorts + console.log(this.userSettings.pins.properties) this.refreshInventory$.next() } From 3b29d5039270201aa2d806df4628c9b54975b842 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 8 Aug 2025 21:03:35 +0000 Subject: [PATCH 23/30] decode ubid fxnal --- src/@seed/api/ubid/ubid.service.ts | 28 +++++- src/@seed/api/ubid/ubid.types.ts | 6 ++ .../list/actions/geocode-modal.component.ts | 12 +-- .../inventory-list/list/actions/index.ts | 1 + .../list/actions/merge-modal.component.ts | 30 +++--- .../list/actions/ubid-decode.component.html | 33 +++++++ .../list/actions/ubid-decode.component.ts | 96 +++++++++++++++++++ .../list/grid/actions.component.html | 2 +- .../list/grid/actions.component.ts | 10 +- 9 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 src/app/modules/inventory-list/list/actions/ubid-decode.component.html create mode 100644 src/app/modules/inventory-list/list/actions/ubid-decode.component.ts diff --git a/src/@seed/api/ubid/ubid.service.ts b/src/@seed/api/ubid/ubid.service.ts index c6cda579..b330f57f 100644 --- a/src/@seed/api/ubid/ubid.service.ts +++ b/src/@seed/api/ubid/ubid.service.ts @@ -7,7 +7,7 @@ import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType, InventoryTypeSingular } from 'app/modules/inventory/inventory.types' import { UserService } from '../user' -import type { Ubid, UbidDetails, UbidResponse, ValidateUbidResponse } from './ubid.types' +import type { DecodeResults, Ubid, UbidDetails, UbidResponse, ValidateUbidResponse } from './ubid.types' @Injectable({ providedIn: 'root' }) export class UbidService { @@ -91,4 +91,30 @@ export class UbidService { }), ) } + + decodeResults(orgId: number, viewIds: number[], type: InventoryType): Observable { + const url = `/api/v3/ubid/decode_results/?organization_id=${orgId}` + const data = { + property_view_ids: type === 'properties' ? viewIds : [], + taxlot_view_ids: type === 'taxlots' ? viewIds : [], + } + return this._httpClient.post(url, data).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching UBID decode results') + }), + ) + } + + decodeByIds(orgId: number, viewIds: number[], type: InventoryType): Observable<{ status: string }> { + const url = `/api/v3/ubid/decode_by_ids/?organization_id=${orgId}` + const data = { + property_view_ids: type === 'properties' ? viewIds : [], + taxlot_view_ids: type === 'taxlots' ? viewIds : [], + } + return this._httpClient.post<{ status: string }>(url, data).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error decoding UBIDs by ID') + }), + ) + } } diff --git a/src/@seed/api/ubid/ubid.types.ts b/src/@seed/api/ubid/ubid.types.ts index 29efe688..4f187b50 100644 --- a/src/@seed/api/ubid/ubid.types.ts +++ b/src/@seed/api/ubid/ubid.types.ts @@ -25,3 +25,9 @@ export type ValidateUbidResponse = { ubid: string; }; } + +export type DecodeResults = { + ubid_not_decoded: number; + ubid_successfully_decoded: number; + ubid_unpopulated: number; +} diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index 98ea2e2e..ce56e02c 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -20,24 +20,24 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { private _geocodeService = inject(GeocodeService) private _unsubscribeAll$ = new Subject() + confidenceSummary: ConfidenceSummary = {} + errorMessage: string geocodingEnabled = true geoColumns: GeocodingColumns = { PropertyState: [], TaxLotState: [], } - confidenceSummary: ConfidenceSummary = {} hasApiKey = true hasEnoughGeoCols = true hasGeoColumns = true - suggestVerify = true notGeocoded = false - pSummary: InventoryConfidenceSummary - tSummary: InventoryConfidenceSummary pMessages: boolean - tMessages: boolean pNotGeocoded: boolean + pSummary: InventoryConfidenceSummary + suggestVerify = true + tMessages: boolean tNotGeocoded: boolean - errorMessage: string + tSummary: InventoryConfidenceSummary geocodeState: 'verify' | 'geocoding' | 'result' | 'fail' = 'verify' diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index 34e627cd..0a76aa2d 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,4 +1,5 @@ export * from './geocode-modal.component' export * from './merge-modal.component' export * from './refresh-metadata-modal.component' +export * from './ubid-decode.component' export * from './update-derived-data.component' diff --git a/src/app/modules/inventory-list/list/actions/merge-modal.component.ts b/src/app/modules/inventory-list/list/actions/merge-modal.component.ts index 4e6e9087..9e0aecc0 100644 --- a/src/app/modules/inventory-list/list/actions/merge-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/merge-modal.component.ts @@ -2,15 +2,17 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import { Column, InventoryService, MatchingService, MappableColumnService } from '@seed/api' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community' +import type { Observable } from 'rxjs' +import { catchError, combineLatest, EMPTY, Subject, take, tap } from 'rxjs' +import type { Column } from '@seed/api' +import { InventoryService, MappableColumnService, MatchingService } from '@seed/api' import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' import { ConfigService } from '@seed/services' -import { AgGridAngular } from 'ag-grid-angular' -import { ColDef, GridApi, GridOptions, GridReadyEvent, RowDragEndEvent, RowSelectedEvent } from 'ag-grid-community' import type { FilterResponse, InventoryType, State } from 'app/modules/inventory/inventory.types' -import { catchError, combineLatest, EMPTY, finalize, Observable, Subject, take, tap } from 'rxjs' @Component({ selector: 'seed-merge-modal', @@ -32,22 +34,22 @@ export class MergeModalComponent implements OnInit, OnDestroy { private _mappableColumnService = inject(MappableColumnService) private _inventoryService = inject(InventoryService) private _unsubscribeAll$ = new Subject() - status: 'loading' | 'review' | 'confirm' | 'complete' | 'error' = 'loading' + colDefs: ColDef[] = [] columns: Column[] = [] + errorMessage: string = null + gridHeight = 400 + gridOptions: GridOptions = { rowDragManaged: true } + gridTheme$ = this._configService.gridTheme$ inventory: FilterResponse - metersExist = false loading: boolean - gridTheme$ = this._configService.gridTheme$ - preGridApi: GridApi + matchingColumnDisplayNames: string[] = [] + metersExist = false postData: Record[] = [] - colDefs: ColDef[] = [] preData: State[] = [] - gridOptions: GridOptions = { rowDragManaged: true } - gridHeight = 400 - title = 'Merge Inventory' + preGridApi: GridApi results: string[] = [] - errorMessage: string = null - matchingColumnDisplayNames: string[] = [] + status: 'loading' | 'review' | 'confirm' | 'complete' | 'error' = 'loading' + title = 'Merge Inventory' data = inject(MAT_DIALOG_DATA) as { cycleId: number; diff --git a/src/app/modules/inventory-list/list/actions/ubid-decode.component.html b/src/app/modules/inventory-list/list/actions/ubid-decode.component.html new file mode 100644 index 00000000..93284d70 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-decode.component.html @@ -0,0 +1,33 @@ + + +
    + @if (status === 'error') { + {{ errorMessage }} + } + + @else if (status === 'inProgress') { + + } + + @else { + + + } +
    + +
    + @if (status === 'review') { + + } @else if (status === 'complete') { + + } +
    \ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts b/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts new file mode 100644 index 00000000..3af2b622 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts @@ -0,0 +1,96 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { UbidService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import type { InventoryType } from 'app/modules/inventory' +import { catchError, EMPTY, filter, Subject, switchMap, take, tap } from 'rxjs' + +@Component({ + selector: 'seed-ubid-decode-modal', + templateUrl: './ubid-decode.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + ], +}) +export class UbidDecodeComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _unsubscribeAll$ = new Subject() + private _ubidService = inject(UbidService) + private _configService = inject(ConfigService) + + colDefs: ColDef[] = [ + { field: 'key', flex: 1 }, + { field: 'value', flex: 0.5 }, + ] + errorMessage: string + gridHeight = 175 + gridTheme$ = this._configService.gridTheme$ + rowData: { key: string; value: number }[] + status: 'review' | 'complete' | 'inProgress' | 'error' = 'inProgress' + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + this._ubidService.decodeResults(this.data.orgId, this.data.viewIds, this.data.type) + .pipe( + take(1), + tap((results) => { + this.rowData = [ + { key: 'UBID Not Yet Decoded', value: results.ubid_not_decoded }, + { key: 'UBID Already Decoded - unlikely to change', value: results.ubid_successfully_decoded }, + { key: 'Missing UBID - will be ignored', value: results.ubid_unpopulated }, + ] + this.status = 'review' + }), + ) + .subscribe() + } + + onSubmit() { + this.status = 'inProgress' + this._ubidService.decodeByIds(this.data.orgId, this.data.viewIds, this.data.type) + .pipe( + filter(({ status }) => status === 'success'), + switchMap(() => this._ubidService.decodeResults(this.data.orgId, this.data.viewIds, this.data.type)), + take(1), + tap((results) => { + this.gridHeight = 135 + this.rowData = [ + { key: 'UBID Not Decoded', value: results.ubid_not_decoded }, + { key: 'UBID Successfully Decoded', value: results.ubid_successfully_decoded }, + ] + this.status = 'complete' + }), + catchError(() => { + this.status = 'error' + this.errorMessage = 'An error occurred while decoding UBIDs.' + return EMPTY + }), + ) + .subscribe() + } + + close(success = false): void { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 5f74b53c..d86e1ca9 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -96,6 +96,6 @@ - +
    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 ce1196b4..f7efe0b6 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -11,7 +11,7 @@ import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.comp import { DQCStartModalComponent } from 'app/modules/data-quality' import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UpdateDerivedDataComponent } from '../actions' +import { GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', @@ -203,6 +203,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openUbidDecodeModal() { + const dialogRef = this._dialog.open(UbidDecodeComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + afterClosed(dialogRef: MatDialogRef) { dialogRef.afterClosed().pipe( filter(Boolean), From 3efe3bbbfc5f98dd6550ea53f373d4d7128f23ac Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 11 Aug 2025 17:45:28 +0000 Subject: [PATCH 24/30] compare ubid fxnal --- src/@seed/api/ubid/ubid.service.ts | 10 ++ .../inventory-list/list/actions/index.ts | 1 + .../list/actions/ubid-compare.component.html | 39 ++++++ .../list/actions/ubid-compare.component.ts | 117 ++++++++++++++++++ .../list/grid/actions.component.html | 2 +- .../list/grid/actions.component.ts | 11 +- 6 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/app/modules/inventory-list/list/actions/ubid-compare.component.html create mode 100644 src/app/modules/inventory-list/list/actions/ubid-compare.component.ts diff --git a/src/@seed/api/ubid/ubid.service.ts b/src/@seed/api/ubid/ubid.service.ts index b330f57f..98f3ad1b 100644 --- a/src/@seed/api/ubid/ubid.service.ts +++ b/src/@seed/api/ubid/ubid.service.ts @@ -117,4 +117,14 @@ export class UbidService { }), ) } + + compareUbids(orgId: number, ubid1: string, ubid2: string): Observable { + const url = `/api/v3/ubid/get_jaccard_index/?organization_id=${orgId}` + return this._httpClient.post<{ status: string; data: number }>(url, { ubid1, ubid2 }).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error comparing UBIDs') + }), + ) + } } diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index 0a76aa2d..f1155580 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,5 +1,6 @@ export * from './geocode-modal.component' export * from './merge-modal.component' export * from './refresh-metadata-modal.component' +export * from './ubid-compare.component' export * from './ubid-decode.component' export * from './update-derived-data.component' diff --git a/src/app/modules/inventory-list/list/actions/ubid-compare.component.html b/src/app/modules/inventory-list/list/actions/ubid-compare.component.html new file mode 100644 index 00000000..92a974ca --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-compare.component.html @@ -0,0 +1,39 @@ + + + +
    +
    + The comparison relies on the Jaccard Index, which ranges from 0.0 (no overlap) to 1.0 (perfect match). +
    + +
    + + UBID 1: + + @if (form.controls.ubid1?.hasError('invalid')) { + Invalid UBID + } + + + + UBID 2: + + Invalid UBID + +
    + +
    + @if (result !== null) { + UBID Comparison: {{ result | number:'1.2-2' }} - {{ jaccardQuality(result) }} + } +
    + +
    + +
    + +
    \ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts b/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts new file mode 100644 index 00000000..2e9c9e95 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts @@ -0,0 +1,117 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { forkJoin, Subject, switchMap, take, tap } from 'rxjs' +import { InventoryService, UbidService } from '@seed/api' +import { ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory' + +@Component({ + selector: 'seed-ubid-compare-modal', + templateUrl: './ubid-compare.component.html', + imports: [ + CommonModule, + FormsModule, + MaterialImports, + ModalHeaderComponent, + ReactiveFormsModule, + ], +}) +export class UbidCompareComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _unsubscribeAll$ = new Subject() + private _ubidService = inject(UbidService) + private _inventoryService = inject(InventoryService) + + ubid1: string + ubid2: string + result: number = null + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + form = new FormGroup({ + ubid1: new FormControl('', [Validators.required]), + ubid2: new FormControl('', [Validators.required]), + }) + + ngOnInit(): void { + this.getUbids() + .pipe( + tap(() => { + this.patchForm() + this.watchForm() + }), + take(1), + ) + .subscribe() + } + + getUbids() { + const { orgId, viewIds, type } = this.data + return forkJoin({ + view1: this._inventoryService.getView(orgId, viewIds[0], type), + view2: this._inventoryService.getView(orgId, viewIds[1], type), + }).pipe( + tap(({ view1, view2 }) => { + this.ubid1 = view1?.state.ubid as string || '' + this.ubid2 = view2?.state.ubid as string || '' + }), + ) + } + + patchForm() { + this.form.patchValue({ + ubid1: this.ubid1, + ubid2: this.ubid2, + }) + } + + watchForm() { + this.watchUbid('ubid1') + this.watchUbid('ubid2') + } + + watchUbid(controlName: string) { + this.form.get(controlName)?.valueChanges + .pipe( + switchMap((value: string) => this._ubidService.validate(this.data.orgId, value)), + tap((result) => { + this.result = null + this.form.get(controlName)?.setErrors(result ? null : { invalid: true }) + }), + ) + .subscribe() + } + + jaccardQuality(jaccard: number) { + if (jaccard <= 0) return 'No Match' + if (jaccard < 0.5) return 'Poor' + return jaccard < 1 ? 'Good' : 'Perfect' + } + + onSubmit() { + const { ubid1, ubid2 } = this.form.value + this._ubidService.compareUbids(this.data.orgId, ubid1, ubid2) + .pipe( + take(1), + tap((result) => { this.result = result }), + ) + .subscribe() + } + + close(): void { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index d86e1ca9..f6cd407d 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -94,7 +94,7 @@
    - +
    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 f7efe0b6..568e2eee 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -11,7 +11,7 @@ import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.comp import { DQCStartModalComponent } from 'app/modules/data-quality' import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' +import { GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidCompareComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', @@ -137,7 +137,6 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } - openAliChangeModal() { const dialogRef = this._dialog.open(AliChangeModalComponent, { width: '40rem', @@ -203,6 +202,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openUbidCompareModal() { + const dialogRef = this._dialog.open(UbidCompareComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + openUbidDecodeModal() { const dialogRef = this._dialog.open(UbidDecodeComponent, { width: '40rem', From 7f61bcb74909cc59eec56571e71b1caad4e6879e Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 12 Aug 2025 16:54:48 +0000 Subject: [PATCH 25/30] ubid editor --- src/@seed/api/ubid/ubid.service.ts | 14 + .../detail/grid/documents-grid.component.ts | 2 +- .../list/grid/actions.component.html | 2 +- .../list/grid/actions.component.ts | 10 +- src/app/modules/inventory/actions/index.ts | 1 + .../actions/ubid-modal.component.html | 39 +++ .../inventory/actions/ubid-modal.component.ts | 248 ++++++++++++++++++ 7 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/app/modules/inventory/actions/ubid-modal.component.html create mode 100644 src/app/modules/inventory/actions/ubid-modal.component.ts diff --git a/src/@seed/api/ubid/ubid.service.ts b/src/@seed/api/ubid/ubid.service.ts index 98f3ad1b..35a89471 100644 --- a/src/@seed/api/ubid/ubid.service.ts +++ b/src/@seed/api/ubid/ubid.service.ts @@ -127,4 +127,18 @@ export class UbidService { }), ) } + + getUbidModelsByView(orgId: number, viewId: number, type: InventoryType): Observable { + const url = `/api/v3/ubid/ubids_by_view/?organization_id=${orgId}` + const data = { + view_id: viewId, + type: type === 'taxlots' ? 'taxlot' : 'property', + } + return this._httpClient.post<{ status: string; data: Ubid[] }>(url, data).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching UBID models') + }), + ) + } } diff --git a/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts index 72078d21..e1716337 100644 --- a/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts @@ -72,7 +72,7 @@ export class DocumentsGridComponent implements OnChanges, OnDestroy { return `
    cloud_download - clear + clear
    ` } diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index f6cd407d..8ed8508a 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -92,7 +92,7 @@ UBID
    - + 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 568e2eee..279d004c 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -9,7 +9,7 @@ import { DeleteModalComponent, MenuItemComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.component' import { DQCStartModalComponent } from 'app/modules/data-quality' -import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' +import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent, UbidModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' import { GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidCompareComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' @@ -202,6 +202,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openUbidModal() { + const dialogRef = this._dialog.open(UbidModalComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + openUbidCompareModal() { const dialogRef = this._dialog.open(UbidCompareComponent, { width: '40rem', diff --git a/src/app/modules/inventory/actions/index.ts b/src/app/modules/inventory/actions/index.ts index 867ca2c3..e7eb86d0 100644 --- a/src/app/modules/inventory/actions/index.ts +++ b/src/app/modules/inventory/actions/index.ts @@ -4,3 +4,4 @@ export * from './analysis-run-modal.component' export * from './groups-modal.component' export * from './labels-modal.component' export * from './export-modal.component' +export * from './ubid-modal.component' diff --git a/src/app/modules/inventory/actions/ubid-modal.component.html b/src/app/modules/inventory/actions/ubid-modal.component.html new file mode 100644 index 00000000..6b40ae0d --- /dev/null +++ b/src/app/modules/inventory/actions/ubid-modal.component.html @@ -0,0 +1,39 @@ + + + +
    + + + + + @if (inProgress) { + + } + + @else if (errMessages.length) { + +
      + @for (msg of errMessages; track $index) { +
    • {{ msg }}
    • + } +
    +
    + } + +
    + +
    + + +
    \ No newline at end of file diff --git a/src/app/modules/inventory/actions/ubid-modal.component.ts b/src/app/modules/inventory/actions/ubid-modal.component.ts new file mode 100644 index 00000000..6a0ddda1 --- /dev/null +++ b/src/app/modules/inventory/actions/ubid-modal.component.ts @@ -0,0 +1,248 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellClickedEvent, CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { filter, finalize, forkJoin, map, of, Subject, switchMap, take, tap } from 'rxjs' +import type { Ubid} from '@seed/api'; +import { InventoryService, UbidService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import type { InventoryType, InventoryTypeSingular, ViewResponse } from '../inventory.types' + +@Component({ + selector: 'seed-ubid-modal', + templateUrl: './ubid-modal.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + ], +}) +export class UbidModalComponent implements OnInit, OnDestroy { + private _configService = inject(ConfigService) + private _dialogRef = inject(MatDialogRef) + private _inventoryService = inject(InventoryService) + private _ubidService = inject(UbidService) + private _unsubscribeAll$ = new Subject() + + gridTheme$ = this._configService.gridTheme$ + stateId: number + viewId: number + ubid: string + ubids: Ubid[] = [] + view: ViewResponse + gridApi: GridApi + colDefs: ColDef[] = [] + gridHeight = 0 + gridOptions = { + singleClickEdit: true, + } + ubidsToDelete: number[] = [] + originalUbids: Ubid[] = [] + errMessages: string[] = [] + inProgress = false + singularType: InventoryTypeSingular + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + this.viewId = this.data.viewIds[0] + this.singularType = this.data.type === 'taxlots' ? 'taxlot' : 'property' + this.getUbids() + } + + getUbids() { + const { orgId, type } = this.data + forkJoin({ + view: this._inventoryService.getView(orgId, this.viewId, type), + ubids: this._ubidService.getUbidModelsByView(orgId, this.viewId, type), + }) + .pipe( + tap(({ view, ubids }) => { + this.view = view + this.stateId = view.state.id + this.ubids = ubids + this.originalUbids = ubids + this.setGrid() + }), + ) + .subscribe() + } + + setGrid() { + // assume all incoming ubids are valid + this.ubids = this.ubids.map((u) => ({ ...u, valid: true })) + this.getGridHeight() + this.colDefs = [ + { + field: 'ubid', + headerName: 'UBID', + flex: 1, + editable: true, + cellRenderer: this.ubidRenderer, + }, + { + field: 'preferred', + headerName: 'Preferred', + flex: 0.5, + editable: true, + onCellValueChanged: this.onPreferredChange, + }, + { + field: 'delete', + headerName: 'Delete', + flex: 0.5, cellRenderer: this.deleteRenderer, + }, + ] + } + + getGridHeight() { + this.gridHeight = Math.min(this.ubids.length * 42 + 50, 400) + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + } + + onCellClicked(event: CellClickedEvent) { + if (event.colDef.field !== 'delete') return + const { id } = event.data as { id: number } + const index = event.rowIndex + + // store id to delete and remove row + if (id) this.ubidsToDelete.push(id) + this.ubids.splice(index, 1) + this.ubids = [...this.ubids] + this.getGridHeight() + } + + ubidRenderer = ({ value }) => { + return ` +
    ${value}
    + ` + } + + deleteRenderer = () => { + return ` +
    + clear +
    + ` + } + + onPreferredChange = (params: CellValueChangedEvent<{ preferred: boolean }>) => { + if (params.newValue) { + params.api.forEachNode((node) => { + if (node.id !== params.node.id && node.data.preferred) { + node.setDataValue('preferred', false) + } + }) + } + } + + addRow() { + const newUbid = { + ubid: '', + preferred: false, + } as Ubid + if (this.data.type === 'taxlots') { + newUbid.taxlot = this.stateId + } else { + newUbid.property = this.stateId + } + this.ubids = [...this.ubids, newUbid] + this.getGridHeight() + } + + onSubmit() { + // stop editing + // clear empty ubids + // delete + // check validity + // update old ones + // create new ones + // save others + this.errMessages = [] + this.gridApi.stopEditing() + this.ubids = this.ubids.filter((u) => u.ubid) + this.inProgress = true + + this.validateNewUbids() + .pipe( + filter(Boolean), + switchMap(() => this.CreateUpdateDeleteUbid()), + finalize(() => { this.inProgress = false }), + ) + .subscribe() + } + + validateNewUbids() { + const originalUbidStrings = this.originalUbids.map((u) => u.ubid) + const ubidsToValidate: string[] = [] + for (const ubid of this.ubids.map((u) => u.ubid)) { + if (!originalUbidStrings.includes(ubid)) { + ubidsToValidate.push(ubid) + } + } + if (!ubidsToValidate.length) return of(true) + + return forkJoin( + ubidsToValidate.map((ubid) => this._ubidService.validate(this.data.orgId, ubid)), + ).pipe( + tap((response) => { + for (const [index, validity] of response.entries()) { + if (!validity) { + this.errMessages.push(`UBID ${ubidsToValidate[index]} is invalid`) + } + } + }), + map((response) => response.every((v) => v)), + take(1), + ) + } + + CreateUpdateDeleteUbid() { + const { orgId, type } = this.data + const createUbids = this.ubids.filter((u) => !u.id) + const updateUbids = this.getUpdateUbids() + + const createDetails = (ubid: Ubid) => ({ ubid: ubid.ubid, preferred: ubid.preferred, [this.singularType]: this.stateId }) + const updateDetails = (ubid: Ubid) => ({ ubid: ubid.ubid, preferred: ubid.preferred }) + + const createRequests = createUbids.map((ubid) => this._ubidService.create(orgId, this.viewId, createDetails(ubid), type)) + const updateRequests = updateUbids.map((ubid) => this._ubidService.update(orgId, this.viewId, ubid.id, updateDetails(ubid), type)) + const deleteRequests = this.ubidsToDelete.map((id) => this._ubidService.delete(this.data.orgId, this.viewId, id, this.data.type)) + + const requests = [...deleteRequests, ...createRequests, ...updateRequests] + + return requests.length ? forkJoin(requests) : of(null) + } + + getUpdateUbids() { + return this.ubids.filter((ubid) => { + if (!ubid.id) return + const oldUbid = this.originalUbids.find((u) => u.id === ubid.id) + return oldUbid ? oldUbid.preferred !== ubid.preferred || oldUbid.ubid !== ubid.ubid : false + }) + } + + close(success = false) { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} From 9617c4bcd024c180e74814a4b2a3f7b37359cc02 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 12 Aug 2025 20:32:21 +0000 Subject: [PATCH 26/30] email modal fxnal --- .../api/postoffice/postoffice.service.ts | 18 +++++- src/@seed/api/postoffice/postoffice.types.ts | 27 ++++++++ .../list/actions/email-modal.component.html | 30 +++++++++ .../list/actions/email-modal.component.ts | 64 +++++++++++++++++++ .../inventory-list/list/actions/index.ts | 1 + .../list/grid/actions.component.html | 2 +- .../list/grid/actions.component.ts | 15 ++++- .../list/inventory.component.html | 1 + .../list/inventory.component.ts | 20 +++++- .../inventory/actions/ubid-modal.component.ts | 3 +- src/app/modules/inventory/inventory.types.ts | 4 ++ 11 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 src/app/modules/inventory-list/list/actions/email-modal.component.html create mode 100644 src/app/modules/inventory-list/list/actions/email-modal.component.ts diff --git a/src/@seed/api/postoffice/postoffice.service.ts b/src/@seed/api/postoffice/postoffice.service.ts index 7587fea0..e4b04207 100644 --- a/src/@seed/api/postoffice/postoffice.service.ts +++ b/src/@seed/api/postoffice/postoffice.service.ts @@ -3,7 +3,7 @@ import { inject, Injectable } from '@angular/core' import { catchError, map, type Observable, ReplaySubject, Subject, takeUntil, tap } from 'rxjs' import { UserService } from '@seed/api' import { ErrorService } from '@seed/services' -import type { CreateEmailTemplateResponse, EmailTemplate, ListEmailTemplatesResponse } from './postoffice.types' +import type { CreateEmailTemplateResponse, EmailTemplate, ListEmailTemplatesResponse, SendEmailResponse } from './postoffice.types' @Injectable({ providedIn: 'root' }) export class PostOfficeService { @@ -66,4 +66,20 @@ export class PostOfficeService { }), ) } + + sendEmail(orgId: number, stateIds: number[], template_id: number, inventory_type: string): Observable { + const url = `/api/v3/postoffice_email/?organization_id=${orgId}` + const data = { + from_email: 'blankl@example.com', // Dummy email. The backend will assign the appropriate email. + template_id, + inventory_id: stateIds, + inventory_type, + } + return this._httpClient.post(url, data) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error sending email') + }), + ) + } } diff --git a/src/@seed/api/postoffice/postoffice.types.ts b/src/@seed/api/postoffice/postoffice.types.ts index 67b8b8d1..02e644ce 100644 --- a/src/@seed/api/postoffice/postoffice.types.ts +++ b/src/@seed/api/postoffice/postoffice.types.ts @@ -20,3 +20,30 @@ export type ListEmailTemplatesResponse = { status: string; data: EmailTemplate[]; } + +export type SendEmailResponse = { + status: string; + data: SentEmailData; +} + +export type SentEmailData = { + backend_alias: string; + bcc: string; + cc: string; + context: string; + created: string; + expires_at: string | null; + from_email: string; + headers: string; + html_message: string; + id: number; + last_updated: string; + message: string; + number_of_retries: number | null; + priority: number; + scheduled_time: string | null; + status: number; + subject: string; + template_id: number; + to: string; +} diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.html b/src/app/modules/inventory-list/list/actions/email-modal.component.html new file mode 100644 index 00000000..66e266cd --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.html @@ -0,0 +1,30 @@ + + +
    + +
    + + Select an Email Template + + @for (template of emailTemplates; track $index) { + {{ template.name }} + } + + + + +
    + +
    + Not seeing your template? + Create one in Organization Settings +
    +
    \ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.ts b/src/app/modules/inventory-list/list/actions/email-modal.component.ts new file mode 100644 index 00000000..e3d6f089 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.ts @@ -0,0 +1,64 @@ +import type { OnDestroy, 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 { RouterModule } from '@angular/router' +import type { EmailTemplate} from '@seed/api' +import { PostOfficeService } from '@seed/api' +import { ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory/inventory.types' +import { Subject, take, tap } from 'rxjs' + +@Component({ + selector: 'seed-email-modal', + templateUrl: './email-modal.component.html', + imports: [ + FormsModule, + MaterialImports, + ModalHeaderComponent, + RouterModule, + ], +}) +export class EmailModalComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _postOfficeService = inject(PostOfficeService) + private _unsubscribeAll$ = new Subject() + + emailTemplates: EmailTemplate[] = [] + selectedTemplateId: number + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + stateIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + this._postOfficeService.getEmailTemplates(this.data.orgId) + .pipe( + tap((emailTemplates) => { this.emailTemplates = emailTemplates }), + take(1), + ) + .subscribe() + } + + // selectTemplate() + onSubmit() { + this._postOfficeService.sendEmail(this.data.orgId, this.data.stateIds, this.selectedTemplateId, this.data.type) + .pipe( + tap((response) => { console.log(response) }), + take(1), + ) + .subscribe() + } + + close() { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} \ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index f1155580..23107561 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,3 +1,4 @@ +export * from './email-modal.component' export * from './geocode-modal.component' export * from './merge-modal.component' export * from './refresh-metadata-modal.component' diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 8ed8508a..933ad69d 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -73,7 +73,7 @@ Other - + () @@ -194,6 +195,18 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openEmailModal() { + const dialogRef = this._dialog.open(EmailModalComponent, { + width: '40rem', + data: { + orgId: this.orgId, + stateIds: this.selectedStateIds, + type: this.type, + }, + }) + this.afterClosed(dialogRef) + } + openGeocodeModal() { const dialogRef = this._dialog.open(GeocodeModalComponent, { width: '40rem', diff --git a/src/app/modules/inventory-list/list/inventory.component.html b/src/app/modules/inventory-list/list/inventory.component.html index a3a02815..f36a2eb9 100644 --- a/src/app/modules/inventory-list/list/inventory.component.html +++ b/src/app/modules/inventory-list/list/inventory.component.html @@ -20,6 +20,7 @@ [orgId]="orgId" [profile]="profile" [profiles]="profiles" + [selectedStateIds]="selectedStateIds" [selectedViewIds]="selectedViewIds" [type]="type" (refreshInventory)="refreshInventory$.next()" diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 2a07f46a..f328c253 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -18,6 +18,7 @@ import type { InventoryType, Pagination, Profile, + State, } from 'app/modules/inventory' import { ActionsComponent, ConfigSelectorComponent, FilterSortChipsComponent, InventoryGridComponent } from './grid' @@ -71,6 +72,7 @@ export class InventoryComponent implements OnDestroy, OnInit { refreshInventory$ = new Subject() rowData: Record[] selectedViewIds: number[] = [] + selectedStateIds: number[] = [] taxlotProfiles: Profile[] userSettings: OrganizationUserSettings = {} @@ -266,13 +268,25 @@ export class InventoryComponent implements OnDestroy, OnInit { } onSelectionChanged() { - this.selectedViewIds = this.type === 'taxlots' - ? this.gridApi.getSelectedRows().map(({ taxlot_view_id }: { taxlot_view_id: number }) => taxlot_view_id) - : this.gridApi.getSelectedRows().map(({ property_view_id }: { property_view_id: number }) => property_view_id) + // this.selectedViewIds = this.type === 'taxlots' + // ? this.gridApi.getSelectedRows().map(({ taxlot_view_id }: { taxlot_view_id: number }) => taxlot_view_id) + // : this.gridApi.getSelectedRows().map(({ property_view_id }: { property_view_id: number }) => property_view_id) + + const selectedRows = this.gridApi.getSelectedRows() as State[] + if (this.type === 'taxlots') { + this.selectedViewIds = selectedRows.map((state) => state.taxlot_view_id) + this.selectedStateIds = selectedRows.map((state) => state.taxlot_state_id) + } else { + this.selectedViewIds = selectedRows.map((state) => state.property_view_id) + this.selectedStateIds = selectedRows.map((state) => state.property_state_id) + } } onSelectAll(selectedViewIds: number[]) { this.selectedViewIds = selectedViewIds + this.selectedStateIds = this.type === 'taxlots' + ? this.gridApi.getSelectedRows().map((state: State) => state.taxlot_state_id) + : this.gridApi.getSelectedRows().map((state: State) => state.property_state_id) } onProfileChange(id: number) { diff --git a/src/app/modules/inventory/actions/ubid-modal.component.ts b/src/app/modules/inventory/actions/ubid-modal.component.ts index 6a0ddda1..ebf5c4d3 100644 --- a/src/app/modules/inventory/actions/ubid-modal.component.ts +++ b/src/app/modules/inventory/actions/ubid-modal.component.ts @@ -107,7 +107,8 @@ export class UbidModalComponent implements OnInit, OnDestroy { } getGridHeight() { - this.gridHeight = Math.min(this.ubids.length * 42 + 50, 400) + const rowLength = this.ubids.length || 1 + this.gridHeight = Math.min(rowLength * 42 + 50, 400) } onGridReady(agGrid: GridReadyEvent) { diff --git a/src/app/modules/inventory/inventory.types.ts b/src/app/modules/inventory/inventory.types.ts index 2f39f5ed..6a1b6b69 100644 --- a/src/app/modules/inventory/inventory.types.ts +++ b/src/app/modules/inventory/inventory.types.ts @@ -239,8 +239,12 @@ export type State = { files: BuildingFile[]; labels: number[]; measures: Record[]; + property_state_id?: number; + property_view_id?: number; related?: State[]; scenarios: Scenario[]; + taxlot_state_id?: number; + taxlot_view_id?: number; } export type UpdateInventoryResponse = { From 139a5f48e220a16cfb10bf27f9ddc10f94d3c0b8 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 12 Aug 2025 22:06:59 +0000 Subject: [PATCH 27/30] FEMP fxnal --- src/@seed/api/inventory/inventory.service.ts | 20 +++++ .../actions/femp-export-modal.component.html | 42 +++++++++++ .../actions/femp-export-modal.component.ts | 75 +++++++++++++++++++ .../inventory-list/list/actions/index.ts | 1 + .../list/grid/actions.component.html | 23 +++--- .../list/grid/actions.component.ts | 10 ++- src/styles/styles.scss | 11 +++ 7 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 src/app/modules/inventory-list/list/actions/femp-export-modal.component.html create mode 100644 src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index 31bd9e87..db5f1a5f 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -406,4 +406,24 @@ export class InventoryService { }), ) } + + evaluationExportToCts(orgId: number, viewIds: number[], filename: string): Observable { + const url = `/api/v3/properties/evaluation_export_to_cts/?organization_id=${orgId}` + return this._httpClient.post(url, { filename, property_view_ids: viewIds }, { responseType: 'arraybuffer' }).pipe( + map((response) => new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error exporting to CTS') + }), + ) + } + + facilityBpsExportToCts(orgId: number, viewIds: number[], filename: string): Observable { + const url = `/api/v3/properties/facility_bps_export_to_cts/?organization_id=${orgId}` + return this._httpClient.post(url, { filename, property_view_ids: viewIds }, { responseType: 'arraybuffer' }).pipe( + map((response) => new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error exporting to CTS') + }), + ) + } } diff --git a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html new file mode 100644 index 00000000..33174712 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html @@ -0,0 +1,42 @@ + + + +
    +
    + +
    + Select a reporting template to export for the Federal Energy Management Program (FEMP) Energy Independence and Security + Act of 2007 (EISA) Compliance Tracking System (CTS) +
    +
    + + + + + CTS Comprehensive Evaluation Upload Template + CTS Facility Upload Template for BPS + + + + Export File Name + + + + @if (inProgress) { + + } + +
    + + +
    + +
    \ No newline at end of file diff --git a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts new file mode 100644 index 00000000..eecd675a --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts @@ -0,0 +1,75 @@ +import { Component, OnDestroy, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog' +import { InventoryService } from '@seed/api' +import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { InventoryType } from 'app/modules/inventory/inventory.types' +import { finalize, Subject, take, tap } from 'rxjs' + +@Component({ + selector: 'app-femp-export-modal', + templateUrl: './femp-export-modal.component.html', + imports: [ + FormsModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + ], +}) +export class FempExportModalComponent implements OnDestroy { + private _dialog = inject(MatDialogRef) + private _inventoryService = inject(InventoryService) + private _snackBar = inject(SnackBarService) + private _unsubscribeAll$ = new Subject() + + exportType: 'evaluation' | 'facility' = 'evaluation' + filename = '' + inProgress = false + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + setExportType(type: 'evaluation' | 'facility'): void { + this.exportType = type + } + + onSubmit() { + this.inProgress = true + const exportRequest = this.exportType === 'evaluation' + ? this._inventoryService.evaluationExportToCts(this.data.orgId, this.data.viewIds, this.filename) + : this._inventoryService.facilityBpsExportToCts(this.data.orgId, this.data.viewIds, this.filename) + + exportRequest + .pipe( + tap((response) => { this.downloadData(response) }), + finalize(() => { + this.inProgress = false + this.close() + }), + ).subscribe() + } + + downloadData(data: Blob) { + const a = document.createElement('a') + const url = URL.createObjectURL(data) + a.href = url + a.download = this.filename + a.click() + URL.revokeObjectURL(url) + this._snackBar.success(`Exported ${this.filename}`) + } + + close(): void { + this._dialog.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index 23107561..5bfa75ed 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,4 +1,5 @@ export * from './email-modal.component' +export * from './femp-export-modal.component' export * from './geocode-modal.component' export * from './merge-modal.component' export * from './refresh-metadata-modal.component' diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 933ad69d..4b410aa1 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -36,12 +36,14 @@
    } - - Audit Template -
    - - -
    + @if (type === 'properties') { + + Audit Template +
    + + +
    + } @@ -74,12 +76,9 @@ Other - + @if (type === 'properties') { + + } 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 232eb987..53b1e7f9 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -11,7 +11,7 @@ import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.comp import { DQCStartModalComponent } from 'app/modules/data-quality' import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent, UbidModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { EmailModalComponent, GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidCompareComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' +import { EmailModalComponent, FempExportModalComponent, GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidCompareComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', @@ -207,6 +207,14 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openFempExportModal() { + const dialogRef = this._dialog.open(FempExportModalComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + openGeocodeModal() { const dialogRef = this._dialog.open(GeocodeModalComponent, { width: '40rem', diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 7d8f6a06..b8064836 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -258,6 +258,17 @@ // } } +.border-button-toggle-group-vertical { + @apply w-fit gap-0 !important; + + .mat-button-toggle { + border: 1px solid rgb(170 170 170) !important; + } + + .mat-button-toggle-checked { + @apply dark:bg-primary dark:bg-opacity-50 !important; + } +} .compact-form { label, span { From bbfca8fcda7095a302553ed2cc0f7104db6d0283 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 13 Aug 2025 15:29:10 +0000 Subject: [PATCH 28/30] lint --- src/@seed/api/matching/matching.service.ts | 2 +- .../list/actions/email-modal.component.html | 23 +++--- .../list/actions/email-modal.component.ts | 6 +- .../actions/femp-export-modal.component.html | 27 +++---- .../actions/femp-export-modal.component.ts | 9 ++- .../list/actions/geocode-modal.component.html | 77 ++++++++++--------- .../list/actions/merge-modal.component.html | 57 ++++++-------- .../list/actions/ubid-compare.component.html | 16 ++-- .../list/actions/ubid-compare.component.ts | 2 +- .../list/actions/ubid-decode.component.html | 23 ++---- .../list/actions/ubid-decode.component.ts | 6 +- .../list/grid/actions.component.html | 19 +++-- .../actions/ubid-modal.component.html | 29 +++---- .../inventory/actions/ubid-modal.component.ts | 2 +- 14 files changed, 129 insertions(+), 169 deletions(-) diff --git a/src/@seed/api/matching/matching.service.ts b/src/@seed/api/matching/matching.service.ts index 2047622b..05410a5d 100644 --- a/src/@seed/api/matching/matching.service.ts +++ b/src/@seed/api/matching/matching.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' -import type { Observable} from 'rxjs'; +import type { Observable } from 'rxjs' import { catchError, tap } from 'rxjs' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.html b/src/app/modules/inventory-list/list/actions/email-modal.component.html index 66e266cd..5d049c83 100644 --- a/src/app/modules/inventory-list/list/actions/email-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.html @@ -1,30 +1,25 @@ - +
    - -
    +
    Select an Email Template - + @for (template of emailTemplates; track $index) { {{ template.name }} } -
    -
    \ No newline at end of file +
    diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.ts b/src/app/modules/inventory-list/list/actions/email-modal.component.ts index e3d6f089..6ea260c3 100644 --- a/src/app/modules/inventory-list/list/actions/email-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.ts @@ -3,12 +3,12 @@ import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { RouterModule } from '@angular/router' -import type { EmailTemplate} from '@seed/api' +import { Subject, take, tap } from 'rxjs' +import type { EmailTemplate } from '@seed/api' import { PostOfficeService } from '@seed/api' import { ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import type { InventoryType } from 'app/modules/inventory/inventory.types' -import { Subject, take, tap } from 'rxjs' @Component({ selector: 'seed-email-modal', @@ -61,4 +61,4 @@ export class EmailModalComponent implements OnInit, OnDestroy { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() } -} \ No newline at end of file +} diff --git a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html index 33174712..683be05e 100644 --- a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html @@ -1,42 +1,35 @@ - - +
    -
    - +
    +
    - Select a reporting template to export for the Federal Energy Management Program (FEMP) Energy Independence and Security - Act of 2007 (EISA) Compliance Tracking System (CTS) + Select a reporting template to export for the Federal Energy Management Program (FEMP) Energy Independence and Security Act of 2007 + (EISA) Compliance Tracking System (CTS)
    + > CTS Comprehensive Evaluation Upload Template CTS Facility Upload Template for BPS Export File Name - + @if (inProgress) { } -
    -
    - -
    \ No newline at end of file + +
    diff --git a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts index eecd675a..a92ffee7 100644 --- a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts @@ -1,15 +1,16 @@ -import { Component, OnDestroy, inject } from '@angular/core' +import type { OnDestroy } from '@angular/core' +import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' -import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { finalize, Subject, tap } from 'rxjs' import { InventoryService } from '@seed/api' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory/inventory.types' -import { finalize, Subject, take, tap } from 'rxjs' @Component({ - selector: 'app-femp-export-modal', + selector: 'seed-femp-export-modal', templateUrl: './femp-export-modal.component.html', imports: [ FormsModule, diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html index 8bcc1ee6..fe9d4e4b 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -2,9 +2,7 @@
    - @if (geocodeState === 'verify') { - @if (!hasGeoColumns) {
    @@ -14,40 +12,40 @@ } - @if (!hasApiKey) { - -
    - {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} - {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }} -
    -
    - } + @if (!hasApiKey) { + +
    + {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} + {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }} +
    +
    + } - @if (!geocodingEnabled) { - - {{ t('Geocoding has been disabled for this organization.') }} - - } + @if (!geocodingEnabled) { + + {{ t('Geocoding has been disabled for this organization.') }} + + } - @if (pSummary?.not_geocoded || tSummary?.not_geocoded) { - - {{ t('NOT_GEOCODED_PREVIOUSLY') }} - @if (pSummary?.not_geocoded) {
  • {{ pSummary?.not_geocoded }} properties
  • } - @if (tSummary?.not_geocoded) {
  • {{ tSummary?.not_geocoded }} tax lots
  • } -
    + @if (pSummary?.not_geocoded || tSummary?.not_geocoded) { + + {{ t('NOT_GEOCODED_PREVIOUSLY') }} + @if (pSummary?.not_geocoded) { +
  • {{ pSummary?.not_geocoded }} properties
  • + } + @if (tSummary?.not_geocoded) { +
  • {{ tSummary?.not_geocoded }} tax lots
  • + } +
    + } } - -} - - - - @if ((pMessages || tMessages)) { + @if (pMessages || tMessages) { @if (geocodeState === 'verify') {
    @@ -56,11 +54,13 @@
    } @if (geocodeState === 'result') { -
    {{ t('POST_GEOCODING_COUNTS')}}
    +
    {{ t('POST_GEOCODING_COUNTS') }}
    } - @if (pMessages) {
    Properties
    } + @if (pMessages) { +
    Properties
    + } @if (pSummary?.high_confidence) {
  • {{ pSummary?.high_confidence }} {{ t('GEOCODED_WITH_HIGH_CONFIDENCE') }}
  • } @@ -68,8 +68,11 @@
  • {{ pSummary?.low_confidence }} {{ t('GEOCODED_WITH_LOW_CONFIDENCE') }}
  • } @if (pSummary?.manual) { -
  • {{ pSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} - @if (geocodeState === 'verify') { - {{ t('ITEMS_WILL_NOT_CHANGE') }}} +
  • + {{ pSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} + @if (geocodeState === 'verify') { + - {{ t('ITEMS_WILL_NOT_CHANGE') }} + }
  • } @if (pSummary?.missing_address_components) { @@ -77,7 +80,9 @@ } - @if (tMessages) {
    Tax Lots
    } + @if (tMessages) { +
    Tax Lots
    + } @if (tSummary?.high_confidence) {
  • {{ tSummary?.high_confidence }} {{ t('GEOCODED_WITH_HIGH_CONFIDENCE') }}
  • } @@ -85,8 +90,11 @@
  • {{ tSummary?.low_confidence }} {{ t('GEOCODED_WITH_LOW_CONFIDENCE') }}
  • } @if (tSummary?.manual) { -
  • {{ tSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} - @if (geocodeState === 'verify') { - {{ t('ITEMS_WILL_NOT_CHANGE') }}} +
  • + {{ tSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} + @if (geocodeState === 'verify') { + - {{ t('ITEMS_WILL_NOT_CHANGE') }} + }
  • } @if (tSummary?.missing_address_components) { @@ -104,7 +112,6 @@ @if (geocodeState === 'geocoding') { } -
    diff --git a/src/app/modules/inventory-list/list/actions/merge-modal.component.html b/src/app/modules/inventory-list/list/actions/merge-modal.component.html index 590f342c..bbec1df6 100644 --- a/src/app/modules/inventory-list/list/actions/merge-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/merge-modal.component.html @@ -1,15 +1,9 @@ - +
    @if (status === 'loading') { - - } - - @else if (status === 'review') { + + } @else if (status === 'review') {
    @@ -21,7 +15,7 @@ [style.height.px]="78" [theme]="gridTheme$ | async" [style.--ag-spacing.px]="6" - > + > @@ -30,63 +24,56 @@ Selected Inventory
    -
    +
    Records will be merged together from bottom to top, with the top record having the highest priority. Drag to reorder.
    - + >
    - } - - @else if (status === 'confirm') { + } @else if (status === 'confirm') { This action cannot be undone -
    - +
    -
      +
      • {{ t('MERGE_IN_CYCLE_MATCHES') }}
      • {{ t('LINK_OUT_CYCLE_MATCHES') }}
      • {{ t('MERGE_MULTIPLE_IN_CYCLE_MATCHES') }}
      - - +
      {{ t('LISTING_ORG_MATCHING_CRITERIA') }}
      -
        - @for (column of matchingColumnDisplayNames; track $index) {
      • {{ column }}
      • } +
          + @for (column of matchingColumnDisplayNames; track $index) { +
        • {{ column }}
        • + }
        -
    - - +
    - } @else if (status === 'complete') {
    Merge completed successfully!
    - -
      - @for (result of results; track $index) {
    • {{ result }}
    • } + +
        + @for (result of results; track $index) { +
      • {{ result }}
      • + }
      - } @else if (status === 'error') { {{ errorMessage }} } - -
    @@ -99,4 +86,4 @@ @if (status === 'complete' || status === 'error') { } -
    \ No newline at end of file +
    diff --git a/src/app/modules/inventory-list/list/actions/ubid-compare.component.html b/src/app/modules/inventory-list/list/actions/ubid-compare.component.html index 92a974ca..238de829 100644 --- a/src/app/modules/inventory-list/list/actions/ubid-compare.component.html +++ b/src/app/modules/inventory-list/list/actions/ubid-compare.component.html @@ -1,9 +1,4 @@ - - +
    @@ -26,14 +21,13 @@ -
    +
    @if (result !== null) { - UBID Comparison: {{ result | number:'1.2-2' }} - {{ jaccardQuality(result) }} + UBID Comparison: {{ result | number: '1.2-2' }} - {{ jaccardQuality(result) }} }
    -
    - -
    \ No newline at end of file + +
    diff --git a/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts b/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts index 2e9c9e95..8c099663 100644 --- a/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts +++ b/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts @@ -84,7 +84,7 @@ export class UbidCompareComponent implements OnInit, OnDestroy { switchMap((value: string) => this._ubidService.validate(this.data.orgId, value)), tap((result) => { this.result = null - this.form.get(controlName)?.setErrors(result ? null : { invalid: true }) + this.form.get(controlName)?.setErrors(result ? null : { invalid: true }) }), ) .subscribe() diff --git a/src/app/modules/inventory-list/list/actions/ubid-decode.component.html b/src/app/modules/inventory-list/list/actions/ubid-decode.component.html index 93284d70..79c46843 100644 --- a/src/app/modules/inventory-list/list/actions/ubid-decode.component.html +++ b/src/app/modules/inventory-list/list/actions/ubid-decode.component.html @@ -1,25 +1,12 @@ - +
    @if (status === 'error') { {{ errorMessage }} - } - - @else if (status === 'inProgress') { + } @else if (status === 'inProgress') { - } - - @else { - + } @else { + }
    @@ -30,4 +17,4 @@ } @else if (status === 'complete') { } -
    \ No newline at end of file +
    diff --git a/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts b/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts index 3af2b622..e8fb1517 100644 --- a/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts +++ b/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts @@ -2,14 +2,14 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { catchError, EMPTY, filter, Subject, switchMap, take, tap } from 'rxjs' import { UbidService } from '@seed/api' import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ConfigService } from '@seed/services' -import { AgGridAngular } from 'ag-grid-angular' -import type { ColDef } from 'ag-grid-community' import type { InventoryType } from 'app/modules/inventory' -import { catchError, EMPTY, filter, Subject, switchMap, take, tap } from 'rxjs' @Component({ selector: 'seed-ubid-decode-modal', diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 4b410aa1..95726eb2 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -16,7 +16,12 @@ - + @@ -40,8 +45,10 @@ Audit Template
    - - + + + +
    } @@ -91,10 +98,8 @@ UBID
    - - - - + +
    diff --git a/src/app/modules/inventory/actions/ubid-modal.component.html b/src/app/modules/inventory/actions/ubid-modal.component.html index 6b40ae0d..ef1526e1 100644 --- a/src/app/modules/inventory/actions/ubid-modal.component.html +++ b/src/app/modules/inventory/actions/ubid-modal.component.html @@ -1,28 +1,20 @@ - - +
    - @if (inProgress) { - } - - @else if (errMessages.length) { - + } @else if (errMessages.length) { +
      @for (msg of errMessages; track $index) {
    • {{ msg }}
    • @@ -30,10 +22,9 @@
    } -
    -
    \ No newline at end of file +
    diff --git a/src/app/modules/inventory/actions/ubid-modal.component.ts b/src/app/modules/inventory/actions/ubid-modal.component.ts index ebf5c4d3..a0fd4f64 100644 --- a/src/app/modules/inventory/actions/ubid-modal.component.ts +++ b/src/app/modules/inventory/actions/ubid-modal.component.ts @@ -5,7 +5,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import { filter, finalize, forkJoin, map, of, Subject, switchMap, take, tap } from 'rxjs' -import type { Ubid} from '@seed/api'; +import type { Ubid } from '@seed/api' import { InventoryService, UbidService } from '@seed/api' import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' From eb58feec9b50a037154bdc27c57a213ce1d1b2ba Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 22 Aug 2025 18:04:39 +0000 Subject: [PATCH 29/30] email refactor --- .../api/postoffice/postoffice.service.ts | 9 ++++ .../list/actions/email-modal.component.ts | 13 +++-- .../email-templates.component.html | 54 +++++++++++-------- .../email-templates.component.ts | 3 +- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/@seed/api/postoffice/postoffice.service.ts b/src/@seed/api/postoffice/postoffice.service.ts index e4b04207..686ac7c1 100644 --- a/src/@seed/api/postoffice/postoffice.service.ts +++ b/src/@seed/api/postoffice/postoffice.service.ts @@ -3,6 +3,7 @@ import { inject, Injectable } from '@angular/core' import { catchError, map, type Observable, ReplaySubject, Subject, takeUntil, tap } from 'rxjs' import { UserService } from '@seed/api' import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { CreateEmailTemplateResponse, EmailTemplate, ListEmailTemplatesResponse, SendEmailResponse } from './postoffice.types' @Injectable({ providedIn: 'root' }) @@ -11,6 +12,7 @@ export class PostOfficeService { private _userService = inject(UserService) private _errorService = inject(ErrorService) private _emailTemplates = new ReplaySubject() + private _snackBar = inject(SnackBarService) private readonly _unsubscribeAll$ = new Subject() orgId: number emailTemplates$ = this._emailTemplates.asObservable() @@ -77,6 +79,13 @@ export class PostOfficeService { } return this._httpClient.post(url, data) .pipe( + tap(({ status }) => { + if (status === 'success') { + this._snackBar.success('Successfully sent email') + } else { + this._snackBar.alert('Error sending email') + } + }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error sending email') }), diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.ts b/src/app/modules/inventory-list/list/actions/email-modal.component.ts index 6ea260c3..6c15f347 100644 --- a/src/app/modules/inventory-list/list/actions/email-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.ts @@ -3,11 +3,12 @@ import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { RouterModule } from '@angular/router' -import { Subject, take, tap } from 'rxjs' +import { finalize, Subject, take, tap } from 'rxjs' import type { EmailTemplate } from '@seed/api' import { PostOfficeService } from '@seed/api' import { ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory/inventory.types' @Component({ @@ -23,6 +24,7 @@ import type { InventoryType } from 'app/modules/inventory/inventory.types' export class EmailModalComponent implements OnInit, OnDestroy { private _dialogRef = inject(MatDialogRef) private _postOfficeService = inject(PostOfficeService) + private _snackBar = inject(SnackBarService) private _unsubscribeAll$ = new Subject() emailTemplates: EmailTemplate[] = [] @@ -37,7 +39,10 @@ export class EmailModalComponent implements OnInit, OnDestroy { ngOnInit(): void { this._postOfficeService.getEmailTemplates(this.data.orgId) .pipe( - tap((emailTemplates) => { this.emailTemplates = emailTemplates }), + tap((emailTemplates) => { + this.emailTemplates = emailTemplates + this.selectedTemplateId = emailTemplates[0]?.id + }), take(1), ) .subscribe() @@ -47,8 +52,10 @@ export class EmailModalComponent implements OnInit, OnDestroy { onSubmit() { this._postOfficeService.sendEmail(this.data.orgId, this.data.stateIds, this.selectedTemplateId, this.data.type) .pipe( - tap((response) => { console.log(response) }), take(1), + finalize(() => { + this.close() + }), ) .subscribe() } diff --git a/src/app/modules/organizations/email-templates/email-templates.component.html b/src/app/modules/organizations/email-templates/email-templates.component.html index 445cdfa7..82e4a5c1 100644 --- a/src/app/modules/organizations/email-templates/email-templates.component.html +++ b/src/app/modules/organizations/email-templates/email-templates.component.html @@ -41,7 +41,7 @@

    Custom Emails

    -
    +
    Templates @@ -65,26 +65,36 @@

    Custom Emails

    -
    - - - Subject - - @if (templateForm.controls.subject?.hasError('required')) { - Subject is a required field - } - + @if (!selectedTemplate) { + +
    + +
    - Content - - @if (templateForm.controls.html_content?.hasError('required')) { - Content is a required field - } -
    - -
    - -
    + } @else { +
    +
    + + Subject + + @if (templateForm.controls.subject?.hasError('required')) { + Subject is a required field + } + + + Content + + @if (templateForm.controls.html_content?.hasError('required')) { + Content is a required field + } +
    + +
    +
    +
    + } diff --git a/src/app/modules/organizations/email-templates/email-templates.component.ts b/src/app/modules/organizations/email-templates/email-templates.component.ts index f841ff95..f268b0c6 100644 --- a/src/app/modules/organizations/email-templates/email-templates.component.ts +++ b/src/app/modules/organizations/email-templates/email-templates.component.ts @@ -6,7 +6,7 @@ import { MatDialog } from '@angular/material/dialog' import { NgxWigModule } from 'ngx-wig' import { filter, map, Subject, switchMap, takeUntil, tap } from 'rxjs' import { type EmailTemplate, PostOfficeService, UserService } from '@seed/api' -import { DeleteModalComponent, PageComponent } from '@seed/components' +import { DeleteModalComponent, NotFoundComponent, PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { naturalSort } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @@ -19,6 +19,7 @@ import { FormModalComponent } from './modal/form-modal.component' CommonModule, MaterialImports, NgxWigModule, + NotFoundComponent, PageComponent, ReactiveFormsModule, ], From a0d6292320884c911aa3c77ecaf3f84e7b56345e Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 22 Aug 2025 18:08:10 +0000 Subject: [PATCH 30/30] prettier --- .../organizations/email-templates/email-templates.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/modules/organizations/email-templates/email-templates.component.html b/src/app/modules/organizations/email-templates/email-templates.component.html index 82e4a5c1..ffa9ed80 100644 --- a/src/app/modules/organizations/email-templates/email-templates.component.html +++ b/src/app/modules/organizations/email-templates/email-templates.component.html @@ -72,7 +72,6 @@

    Custom Emails

    Create New Template
    - } @else {