From f8f24ae35b4316b1560f6fc984f95bcfddcb133f Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 24 Jun 2025 21:58:04 +0000 Subject: [PATCH 01/50] refactor for ag grid --- src/app/modules/data/data.component.html | 17 ++++++- src/app/modules/data/data.component.ts | 60 ++++++++++++++++++++---- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/app/modules/data/data.component.html b/src/app/modules/data/data.component.html index b8dad0b0..16836e2e 100644 --- a/src/app/modules/data/data.component.html +++ b/src/app/modules/data/data.component.html @@ -7,8 +7,21 @@ actionText: 'Create Dataset', }" > +
+ @if (datasets.length) { + + } +
-
+ diff --git a/src/app/modules/data/data.component.ts b/src/app/modules/data/data.component.ts index 5d4f3789..0586104e 100644 --- a/src/app/modules/data/data.component.ts +++ b/src/app/modules/data/data.component.ts @@ -10,22 +10,37 @@ import { from, skip } from 'rxjs' import type { Dataset } from '@seed/api/dataset' import { UserService } from '@seed/api/user' import { PageComponent } from '@seed/components' +import { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { AgGridAngular } from 'ag-grid-angular' +import { ConfigService } from '@seed/services' @Component({ selector: 'seed-data', templateUrl: './data.component.html', encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, MatButtonModule, MatIconModule, MatSortModule, MatTableModule, PageComponent], + // changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AgGridAngular, + CommonModule, + MatButtonModule, + MatIconModule, + MatSortModule, + MatTableModule, + PageComponent, + ], }) export class DataComponent implements OnInit, AfterViewInit { + private _configService = inject(ConfigService) private _route = inject(ActivatedRoute) private _router = inject(Router) private _userService = inject(UserService) - readonly sort = viewChild.required(MatSort) - datasetsDataSource = new MatTableDataSource() + // datasetsDataSource = new MatTableDataSource() + datasets: Dataset[] datasetsColumns = ['name', 'importfiles', 'updated_at', 'last_modified_by', 'actions'] + gridApi: GridApi + gridTheme$ = this._configService.gridTheme$ + columnDefs: ColDef[] ngOnInit(): void { this._init() @@ -38,12 +53,40 @@ export class DataComponent implements OnInit, AfterViewInit { }) } - createDataset(): void { - console.log('create dataset') + setColumnDefs() { + this.columnDefs = [ + { field: 'id', hide: true }, + { field: 'name', headerName: 'Name' }, + { field: 'importfiles', headerName: 'Files', valueGetter: ({ data }: { data: Dataset }) => data.importfiles.length }, + { field: 'updated_at', headerName: 'Updated At', valueGetter: ({ data }: { data: Dataset }) => new Date(data.updated_at).toLocaleDateString() }, + { field: 'last_modified_by', headerName: 'Last Modified By' }, + { field: 'actions', headerName: 'Actions', cellRenderer: this.actionsRenderer }, + ] + } + + actionsRenderer({ data }: { data: Dataset}) { + return ` +
+ plus + clear + edit +
+ ` } ngAfterViewInit(): void { - this.datasetsDataSource.sort = this.sort() + return + // this.datasetsDataSource.sort = this.sort() + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + this.gridApi.sizeColumnsToFit() + // this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + } + + createDataset(): void { + console.log('create dataset') } trackByFn(_index: number, { id }: Dataset) { @@ -51,6 +94,7 @@ export class DataComponent implements OnInit, AfterViewInit { } private _init() { - this.datasetsDataSource.data = this._route.snapshot.data.datasets as Dataset[] + this.setColumnDefs() + this.datasets = this._route.snapshot.data.datasets as Dataset[] } } From 3fe9cfe33d73d7ca060abaf5f14bd614e0fd4b5d Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 25 Jun 2025 19:08:52 +0000 Subject: [PATCH 02/50] temp - register ag gird in main.ts --- src/main.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main.ts b/src/main.ts index 140797f3..14c1329d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,18 @@ import { bootstrapApplication } from '@angular/platform-browser' +import { ClientSideRowModelModule, ColumnAutoSizeModule, EventApiModule, ModuleRegistry, PaginationModule, ValidationModule } from 'ag-grid-community' import { AppComponent } from 'app/app.component' import { appConfig } from 'app/app.config' +// TEMP - should be overwritten when analyses pr is merged + +ModuleRegistry.registerModules([ + ClientSideRowModelModule, + ColumnAutoSizeModule, + EventApiModule, + PaginationModule, + ValidationModule, +]) + bootstrapApplication(AppComponent, appConfig).catch((err: unknown) => { console.error(err) }) From d1f8d1fc2033294b28d2c0db8dff11d9bf2426aa Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 25 Jun 2025 19:39:14 +0000 Subject: [PATCH 03/50] renove unused class --- .../components/column-profiles/column-profiles.component.ts | 2 +- .../detail/grid/building-files-grid.component.ts | 2 +- .../detail/grid/documents-grid.component.ts | 4 ++-- .../inventory-detail/detail/grid/paired-grid.component.ts | 2 +- .../detail/grid/scenarios-grid.component.ts | 2 +- src/app/modules/inventory-detail/meters/meters.component.ts | 4 ++-- src/app/modules/inventory-detail/notes/notes.component.ts | 4 ++-- .../sensors/data-loggers/data-loggers-grid.component.ts | 4 ++-- .../sensor-readings/sensor-readings-grid.component.ts | 4 ++-- .../sensors/sensors/sensors-grid.component.ts | 4 ++-- src/app/modules/inventory-detail/ubids/ubids.component.ts | 6 +++--- src/app/modules/inventory-list/groups/groups.component.ts | 4 ++-- .../matching-criteria/matching-criteria.component.ts | 2 +- 13 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/@seed/components/column-profiles/column-profiles.component.ts b/src/@seed/components/column-profiles/column-profiles.component.ts index e1b2aa3b..33b33e84 100644 --- a/src/@seed/components/column-profiles/column-profiles.component.ts +++ b/src/@seed/components/column-profiles/column-profiles.component.ts @@ -178,7 +178,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { // return ` // // push_pin // 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 2c557659..488ecbd6 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 @@ -56,7 +56,7 @@ export class BuildingFilesGridComponent implements OnInit { actionRenderer = () => { return `
- cloud_download + cloud_download
` } 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 4c334f35..3f5c8f38 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 @@ -75,8 +75,8 @@ export class DocumentsGridComponent implements OnChanges, OnDestroy { actionRenderer = () => { return `
- cloud_download - clear + cloud_download + clear
` } diff --git a/src/app/modules/inventory-detail/detail/grid/paired-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/paired-grid.component.ts index 66737cd0..3dcc9402 100644 --- a/src/app/modules/inventory-detail/detail/grid/paired-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/paired-grid.component.ts @@ -103,7 +103,7 @@ export class PairedGridComponent implements OnChanges, OnDestroy { } unpairRenderer = () => { - return 'clear' + return 'clear' } get gridHeight() { diff --git a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts index 16342344..09e0f614 100644 --- a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts @@ -78,7 +78,7 @@ export class ScenariosGridComponent implements OnChanges { } actionRenderer = () => { - return 'clear' + return 'clear' } onCellClicked(event: CellClickedEvent) { diff --git a/src/app/modules/inventory-detail/meters/meters.component.ts b/src/app/modules/inventory-detail/meters/meters.component.ts index 13df459d..1b33c8f0 100644 --- a/src/app/modules/inventory-detail/meters/meters.component.ts +++ b/src/app/modules/inventory-detail/meters/meters.component.ts @@ -179,8 +179,8 @@ export class MetersComponent implements OnDestroy, OnInit { actionRenderer = () => { return `
- clear - ${this.groupIds.length ? 'edit' : ''} + clear + ${this.groupIds.length ? 'edit' : ''}
` } diff --git a/src/app/modules/inventory-detail/notes/notes.component.ts b/src/app/modules/inventory-detail/notes/notes.component.ts index 94cc799f..5bbe462e 100644 --- a/src/app/modules/inventory-detail/notes/notes.component.ts +++ b/src/app/modules/inventory-detail/notes/notes.component.ts @@ -147,8 +147,8 @@ export class NotesComponent implements OnDestroy, OnInit { const canEdit = params.data.type === 'Manually Created' return `
- clear - ${canEdit ? 'edit' : ''} + clear + ${canEdit ? 'edit' : ''}
` } diff --git a/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts b/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts index 005db30d..ed590f7b 100644 --- a/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts +++ b/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts @@ -86,8 +86,8 @@ export class DataLoggersGridComponent implements OnChanges { add Readings - edit - clear + edit + clear
` } diff --git a/src/app/modules/inventory-detail/sensors/sensor-readings/sensor-readings-grid.component.ts b/src/app/modules/inventory-detail/sensors/sensor-readings/sensor-readings-grid.component.ts index 0b1b6b27..d2d50601 100644 --- a/src/app/modules/inventory-detail/sensors/sensor-readings/sensor-readings-grid.component.ts +++ b/src/app/modules/inventory-detail/sensors/sensor-readings/sensor-readings-grid.component.ts @@ -74,8 +74,8 @@ export class SensorReadingsGridComponent implements OnChanges { actionRenderer() { return `
- edit - clear + edit + clear
` } diff --git a/src/app/modules/inventory-detail/sensors/sensors/sensors-grid.component.ts b/src/app/modules/inventory-detail/sensors/sensors/sensors-grid.component.ts index 5e23ec83..20a78f18 100644 --- a/src/app/modules/inventory-detail/sensors/sensors/sensors-grid.component.ts +++ b/src/app/modules/inventory-detail/sensors/sensors/sensors-grid.component.ts @@ -84,8 +84,8 @@ export class SensorsGridComponent implements OnChanges { actionRenderer() { return `
- edit - clear + edit + clear
` } diff --git a/src/app/modules/inventory-detail/ubids/ubids.component.ts b/src/app/modules/inventory-detail/ubids/ubids.component.ts index 16558239..6f90e339 100644 --- a/src/app/modules/inventory-detail/ubids/ubids.component.ts +++ b/src/app/modules/inventory-detail/ubids/ubids.component.ts @@ -116,7 +116,7 @@ export class UbidsComponent implements OnDestroy, OnInit { return `
- check_circle + check_circle
` } @@ -124,8 +124,8 @@ export class UbidsComponent implements OnDestroy, OnInit { actionRenderer = () => { return `
- edit - clear + edit + clear
` } diff --git a/src/app/modules/inventory-list/groups/groups.component.ts b/src/app/modules/inventory-list/groups/groups.component.ts index 497c176e..65d77422 100644 --- a/src/app/modules/inventory-list/groups/groups.component.ts +++ b/src/app/modules/inventory-list/groups/groups.component.ts @@ -90,8 +90,8 @@ export class GroupsComponent implements OnDestroy, OnInit { actionRenderer = () => { return `
- edit - clear + edit + clear
` } diff --git a/src/app/modules/organizations/columns/matching-criteria/matching-criteria.component.ts b/src/app/modules/organizations/columns/matching-criteria/matching-criteria.component.ts index 594d3223..df227f9e 100644 --- a/src/app/modules/organizations/columns/matching-criteria/matching-criteria.component.ts +++ b/src/app/modules/organizations/columns/matching-criteria/matching-criteria.component.ts @@ -78,7 +78,7 @@ export class MatchingCriteriaComponent implements OnDestroy { actionRenderer = () => { return `
- clear + clear
` } From b968b649fcfea5083aa87a96ad8a73b954839652 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 25 Jun 2025 21:18:26 +0000 Subject: [PATCH 04/50] datasets grid --- src/@seed/api/dataset/dataset.service.ts | 87 +++++++++--- src/app/modules/data/data.component.html | 62 +-------- src/app/modules/data/data.component.ts | 125 ++++++++++++------ src/app/modules/data/data.routes.ts | 17 +-- .../data/modal/form-modal.component.html | 24 ++++ .../data/modal/form-modal.component.ts | 61 +++++++++ .../meters/meters.component.ts | 3 +- .../sensors/sensors.component.ts | 2 +- 8 files changed, 249 insertions(+), 132 deletions(-) create mode 100644 src/app/modules/data/modal/form-modal.component.html create mode 100644 src/app/modules/data/modal/form-modal.component.ts diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index 9e82cfee..347a20f2 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -2,43 +2,94 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { catchError, map, of, ReplaySubject } from 'rxjs' +import { catchError, combineLatest, map, of, ReplaySubject, switchMap, tap } from 'rxjs' import { UserService } from '../user' import type { CountDatasetsResponse, Dataset, ListDatasetsResponse } from './dataset.types' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @Injectable({ providedIn: 'root' }) export class DatasetService { private _httpClient = inject(HttpClient) private _userService = inject(UserService) - + private _errorService = inject(ErrorService) + private _snackBar = inject(SnackBarService) private _datasetCount = new ReplaySubject(1) + private _datasets = new ReplaySubject(1) datasetCount$ = this._datasetCount.asObservable() + datasets$ = this._datasets.asObservable() + orgId: number constructor() { // Refresh dataset count only when the organization ID changes - this._userService.currentOrganizationId$.subscribe((organizationId) => { - this.countDatasets(organizationId).subscribe() - }) + this._userService.currentOrganizationId$.pipe( + tap((orgId) => { + this.orgId = orgId + this.list(this.orgId) + this.countDatasets(this.orgId) + }), + ).subscribe() + } + + list(organizationId: number) { + const url = `/api/v3/datasets/?organization_id=${organizationId}` + this._httpClient.get(url).pipe( + map(({ datasets }) => datasets), + tap((datasets) => { this._datasets.next(datasets) }), + ).subscribe() + } + + create(orgId: number, name: string): Observable { + const url = `/api/v3/datasets/?organization_id=${orgId}` + return this._httpClient.post(url, { name }).pipe( + tap((response) => { console.log('temp', response) }), + tap(() => { + this.countDatasets(orgId) + this.list(orgId) + this._snackBar.success('Dataset created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating dataset count') + }), + ) } - listDatasets(organizationId: number): Observable { - return this._httpClient - .get(`/api/v3/datasets/?organization_id=${organizationId}`) - .pipe(map(({ datasets }) => datasets)) + update(orgId: number, datasetId: number, name: string): Observable { + const url = `/api/v3/datasets/${datasetId}/?organization_id=${orgId}` + return this._httpClient.put(url, { dataset: name }).pipe( + tap((response) => { console.log('temp', response) }), + tap(() => { + this.countDatasets(orgId) + this.list(orgId) + this._snackBar.success('Dataset updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating dataset') + }), + ) } - countDatasets(organizationId: number): Observable { - return this._httpClient.get(`/api/v3/datasets/count/?organization_id=${organizationId}`).pipe( - map(({ datasets_count }) => { - // This assumes that the organizationId passed in is the selected organization - this._datasetCount.next(datasets_count) - return datasets_count + delete(orgId: number, datasetId: number) { + const url = `/api/v3/datasets/${datasetId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this.countDatasets(orgId) + this.list(orgId) + this._snackBar.success('Dataset deleted successfully') }), catchError((error: HttpErrorResponse) => { - // TODO toast or alert? also, better fallback value - console.error('Error occurred while counting datasets:', error.error) - return of(-1) + return this._errorService.handleError(error, 'Error deleting dataset') }), ) } + + countDatasets(orgId: number) { + this._httpClient.get(`/api/v3/datasets/count/?organization_id=${orgId}`).pipe( + map(({ datasets_count }) => datasets_count), + tap((datasetsCount) => { this._datasetCount.next(datasetsCount)}), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching dataset count') + }), + ).subscribe() + } } diff --git a/src/app/modules/data/data.component.html b/src/app/modules/data/data.component.html index 16836e2e..64b1fcf7 100644 --- a/src/app/modules/data/data.component.html +++ b/src/app/modules/data/data.component.html @@ -7,7 +7,7 @@ actionText: 'Create Dataset', }" > -
+
@if (datasets.length) { }
- - - diff --git a/src/app/modules/data/data.component.ts b/src/app/modules/data/data.component.ts index 0586104e..49bca591 100644 --- a/src/app/modules/data/data.component.ts +++ b/src/app/modules/data/data.component.ts @@ -1,18 +1,19 @@ import { CommonModule } from '@angular/common' -import type { AfterViewInit, OnInit } from '@angular/core' -import { ChangeDetectionStrategy, Component, inject, viewChild, ViewEncapsulation } from '@angular/core' +import type { OnInit } from '@angular/core' +import { Component, inject, viewChild, ViewEncapsulation } from '@angular/core' import { MatButtonModule } from '@angular/material/button' +import { MatDialog } from '@angular/material/dialog' import { MatIconModule } from '@angular/material/icon' -import { MatSort, MatSortModule } from '@angular/material/sort' -import { MatTableDataSource, MatTableModule } from '@angular/material/table' import { ActivatedRoute, Router } from '@angular/router' -import { from, skip } from 'rxjs' -import type { Dataset } from '@seed/api/dataset' -import { UserService } from '@seed/api/user' -import { PageComponent } from '@seed/components' -import { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import { AgGridAngular } from 'ag-grid-angular' +import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { filter, switchMap, tap } from 'rxjs' +import { type Dataset, DatasetService } from '@seed/api/dataset' +import { UserService } from '@seed/api/user' +import { DeleteModalComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' +import { FormModalComponent } from './modal/form-modal.component' @Component({ selector: 'seed-data', @@ -24,33 +25,45 @@ import { ConfigService } from '@seed/services' CommonModule, MatButtonModule, MatIconModule, - MatSortModule, - MatTableModule, PageComponent, ], }) -export class DataComponent implements OnInit, AfterViewInit { +export class DataComponent implements OnInit { private _configService = inject(ConfigService) + private _datasetService = inject(DatasetService) private _route = inject(ActivatedRoute) private _router = inject(Router) private _userService = inject(UserService) readonly sort = viewChild.required(MatSort) - // datasetsDataSource = new MatTableDataSource() + private _dialog = inject(MatDialog) + columnDefs: ColDef[] datasets: Dataset[] datasetsColumns = ['name', 'importfiles', 'updated_at', 'last_modified_by', 'actions'] + existingNames: string[] = [] gridApi: GridApi gridTheme$ = this._configService.gridTheme$ - columnDefs: ColDef[] + orgId: number ngOnInit(): void { - this._init() - // Rerun resolver and initializer on org change - this._userService.currentOrganizationId$.pipe(skip(1)).subscribe(() => { - from(this._router.navigate([this._router.url])).subscribe(() => { - this._init() - }) - }) + // this._userService.currentOrganizationId$.pipe(skip(1)).subscribe(() => { + // from(this._router.navigate([this._router.url])).subscribe(() => { + // this._init() + // }) + // }) + + this._userService.currentOrganizationId$.pipe( + tap((orgId) => { + this.orgId = orgId + this._datasetService.list(orgId) + }), + switchMap(() => this._datasetService.datasets$), + tap((datasets) => { + this.datasets = datasets.sort((a, b) => naturalSort(a.name, b.name)) + this.existingNames = datasets.map((ds) => ds.name) + this.setColumnDefs() + }), + ).subscribe() } setColumnDefs() { @@ -64,37 +77,71 @@ export class DataComponent implements OnInit, AfterViewInit { ] } - actionsRenderer({ data }: { data: Dataset}) { + actionsRenderer() { return ` -
- plus - clear - edit +
+ + add + Data Files + + edit + clear
` } - ngAfterViewInit(): void { - return - // this.datasetsDataSource.sort = this.sort() - } - onGridReady(agGrid: GridReadyEvent) { this.gridApi = agGrid.api this.gridApi.sizeColumnsToFit() - // this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) } - createDataset(): void { - console.log('create dataset') + onCellClicked(event: CellClickedEvent) { + console.log('cell clicked', event) + if (event.colDef.field !== 'actions') return + + const target = event.event.target as HTMLElement + const action = target.closest('[data-action]')?.getAttribute('data-action') + const { id } = event.data as { id: number } + const dataset = this.datasets.find((ds) => ds.id === id) + + if (action === 'addDataFiles') { + console.log('add data files', dataset) + } else if (action === 'rename') { + this.editDataset(dataset) + } else if (action === 'delete') { + this.deleteDataset(dataset) + } } - trackByFn(_index: number, { id }: Dataset) { - return id + editDataset(dataset: Dataset) { + const existingNames = this.existingNames.filter((n) => n !== dataset.name) + this._dialog.open(FormModalComponent, { + width: '40rem', + data: { orgId: this.orgId, dataset, existingNames }, + }) + } + + deleteDataset(dataset: Dataset) { + const dialogRef = this._dialog.open(DeleteModalComponent, { + width: '40rem', + data: { model: 'Dataset', instance: dataset.name }, + }) + + dialogRef.afterClosed().pipe( + filter(Boolean), + switchMap(() => this._datasetService.delete(this.orgId, dataset.id)), + ).subscribe() } - private _init() { - this.setColumnDefs() - this.datasets = this._route.snapshot.data.datasets as Dataset[] + createDataset = () => { + this._dialog.open(FormModalComponent, { + width: '40rem', + data: { orgId: this.orgId, dataset: null, existingNames: this.existingNames }, + }) + } + + trackByFn(_index: number, { id }: Dataset) { + return id } } diff --git a/src/app/modules/data/data.routes.ts b/src/app/modules/data/data.routes.ts index 33aa17dc..dd53b6f5 100644 --- a/src/app/modules/data/data.routes.ts +++ b/src/app/modules/data/data.routes.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core' import type { Routes } from '@angular/router' -import { switchMap, take } from 'rxjs' +import { switchMap, take, tap } from 'rxjs' import { DatasetService } from '@seed/api/dataset' import { UserService } from '@seed/api/user' import { DataComponent } from './data.component' @@ -14,13 +14,7 @@ export default [ resolve: { datasets: () => { const datasetService = inject(DatasetService) - const userService = inject(UserService) - return userService.currentOrganizationId$.pipe( - take(1), - switchMap((organizationId) => { - return datasetService.listDatasets(organizationId) - }), - ) + return datasetService.datasets$ }, }, }, @@ -33,11 +27,10 @@ export default [ const datasetService = inject(DatasetService) const userService = inject(UserService) return userService.currentOrganizationId$.pipe( + // TODO retrieve a single dataset instead take(1), - switchMap((organizationId) => { - // TODO retrieve a single dataset instead - return datasetService.listDatasets(organizationId) - }), + tap((orgId) => { datasetService.list(orgId) }), + switchMap(() => datasetService.datasets$), ) }, }, diff --git a/src/app/modules/data/modal/form-modal.component.html b/src/app/modules/data/modal/form-modal.component.html new file mode 100644 index 00000000..63a93e85 --- /dev/null +++ b/src/app/modules/data/modal/form-modal.component.html @@ -0,0 +1,24 @@ +
+ +
{{ create ? 'Create' : 'Edit' }} Dataset
+
+ + +
+ + Dataset Name + + @if (form.controls.name?.hasError('valueExists')) { + This name already exists. + } + +
+ +
+ + + + + + +
\ No newline at end of file diff --git a/src/app/modules/data/modal/form-modal.component.ts b/src/app/modules/data/modal/form-modal.component.ts new file mode 100644 index 00000000..875d7d1a --- /dev/null +++ b/src/app/modules/data/modal/form-modal.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common' +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import type { Dataset } from '@seed/api/dataset' +import { DatasetService } from '@seed/api/dataset' +import { SEEDValidators } from '@seed/validators' + +@Component({ + selector: 'seed-dataset-form-modal', + templateUrl: './form-modal.component.html', + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatDividerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + ReactiveFormsModule, + ], +}) +export class FormModalComponent implements OnInit { + private _dialogRef = inject(MatDialogRef) + private _datasetService = inject(DatasetService) + data = inject(MAT_DIALOG_DATA) as { orgId: number; dataset: Dataset; existingNames?: string[] } + form = new FormGroup({ + name: new FormControl('', [Validators.required, SEEDValidators.uniqueValue(this.data.existingNames)]), + }) + create = this.data.dataset ? false : true + + ngOnInit() { + if (this.data.dataset) { + this.form.patchValue({ name: this.data.dataset.name }) + } + } + + onSubmit() { + if (!this.form.valid) return + + if (this.create) { + this._datasetService.create(this.data.orgId, this.form.value.name).subscribe(() => { + this.dismiss() + }) + } else { + this._datasetService.update(this.data.orgId, this.data.dataset.id, this.form.value.name).subscribe(() => { + this.dismiss() + }) + } + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/inventory-detail/meters/meters.component.ts b/src/app/modules/inventory-detail/meters/meters.component.ts index 1b33c8f0..e57b7c94 100644 --- a/src/app/modules/inventory-detail/meters/meters.component.ts +++ b/src/app/modules/inventory-detail/meters/meters.component.ts @@ -113,7 +113,6 @@ export class MetersComponent implements OnDestroy, OnInit { this._meterService.list(this.orgId, this.viewId) this._meterService.listReadings(this.orgId, this.viewId, this.interval, this.excludedIds) this._groupsService.listForInventory(this.orgId, [this.viewId]) - this._cycleService.get(this.orgId) this._meterService.meters$.pipe( tap((meters) => { @@ -141,7 +140,7 @@ export class MetersComponent implements OnDestroy, OnInit { tap((cycles) => { this.cycles = cycles }), ).subscribe() - this._datasetService.listDatasets(this.orgId).pipe( + this._datasetService.datasets$.pipe( tap((datasets) => { this.datasets = datasets }), ).subscribe() } diff --git a/src/app/modules/inventory-detail/sensors/sensors.component.ts b/src/app/modules/inventory-detail/sensors/sensors.component.ts index 5cfad288..f0c1d6c1 100644 --- a/src/app/modules/inventory-detail/sensors/sensors.component.ts +++ b/src/app/modules/inventory-detail/sensors/sensors.component.ts @@ -82,7 +82,7 @@ export class SensorsComponent implements OnDestroy, OnInit { tap(() => { this._cycleService.get(this.orgId) }), switchMap(() => this._cycleService.cycles$), tap((cycles) => { this.cycleId = cycles.length ? cycles[0].id : null }), - switchMap(() => this._datasetService.listDatasets(this.orgId)), + switchMap(() => this._datasetService.datasets$), tap((datasets) => { this.datasetId = datasets.length ? datasets[0].id.toString() : null }), ) } From 38e8698ee36e8ad975bd21b8180dd9906e39b10f Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 25 Jun 2025 21:20:39 +0000 Subject: [PATCH 05/50] rename --- src/app/app.routes.ts | 2 +- .../data.component.html => datasets/datasets.component.html} | 0 .../{data/data.component.ts => datasets/datasets.component.ts} | 0 .../{data/data.routes.ts => datasets/datasets.routes.ts} | 2 +- .../modules/{data => datasets}/modal/form-modal.component.html | 0 .../modules/{data => datasets}/modal/form-modal.component.ts | 0 6 files changed, 2 insertions(+), 2 deletions(-) rename src/app/modules/{data/data.component.html => datasets/datasets.component.html} (100%) rename src/app/modules/{data/data.component.ts => datasets/datasets.component.ts} (100%) rename src/app/modules/{data/data.routes.ts => datasets/datasets.routes.ts} (94%) rename src/app/modules/{data => datasets}/modal/form-modal.component.html (100%) rename src/app/modules/{data => datasets}/modal/form-modal.component.ts (100%) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 52bfbd88..c8bb6569 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -73,7 +73,7 @@ export const appRoutes: Route[] = [ }, { path: 'data', - loadChildren: () => import('app/modules/data/data.routes'), + loadChildren: () => import('app/modules/datasets/datasets.routes'), }, { path: 'documentation', title: 'Documentation', component: DocumentationComponent }, { diff --git a/src/app/modules/data/data.component.html b/src/app/modules/datasets/datasets.component.html similarity index 100% rename from src/app/modules/data/data.component.html rename to src/app/modules/datasets/datasets.component.html diff --git a/src/app/modules/data/data.component.ts b/src/app/modules/datasets/datasets.component.ts similarity index 100% rename from src/app/modules/data/data.component.ts rename to src/app/modules/datasets/datasets.component.ts diff --git a/src/app/modules/data/data.routes.ts b/src/app/modules/datasets/datasets.routes.ts similarity index 94% rename from src/app/modules/data/data.routes.ts rename to src/app/modules/datasets/datasets.routes.ts index dd53b6f5..1234cfbc 100644 --- a/src/app/modules/data/data.routes.ts +++ b/src/app/modules/datasets/datasets.routes.ts @@ -3,7 +3,7 @@ import type { Routes } from '@angular/router' import { switchMap, take, tap } from 'rxjs' import { DatasetService } from '@seed/api/dataset' import { UserService } from '@seed/api/user' -import { DataComponent } from './data.component' +import { DataComponent } from './datasets.component' export default [ { diff --git a/src/app/modules/data/modal/form-modal.component.html b/src/app/modules/datasets/modal/form-modal.component.html similarity index 100% rename from src/app/modules/data/modal/form-modal.component.html rename to src/app/modules/datasets/modal/form-modal.component.html diff --git a/src/app/modules/data/modal/form-modal.component.ts b/src/app/modules/datasets/modal/form-modal.component.ts similarity index 100% rename from src/app/modules/data/modal/form-modal.component.ts rename to src/app/modules/datasets/modal/form-modal.component.ts From eed0813a32bd9dec51bfa65ed4e24f0750d75d40 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 25 Jun 2025 21:30:35 +0000 Subject: [PATCH 06/50] route rename to datasets --- src/app/app.routes.ts | 2 +- src/app/core/navigation/navigation.service.ts | 8 +++---- .../modules/datasets/datasets.component.ts | 21 +++++++++++++------ src/app/modules/datasets/datasets.routes.ts | 6 +++--- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c8bb6569..bd6911db 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -72,7 +72,7 @@ export const appRoutes: Route[] = [ loadChildren: () => import('app/modules/inventory/inventory.routes'), }, { - path: 'data', + path: 'datasets', loadChildren: () => import('app/modules/datasets/datasets.routes'), }, { path: 'documentation', title: 'Documentation', component: DocumentationComponent }, diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts index 32b6d0b2..5c73cdaf 100644 --- a/src/app/core/navigation/navigation.service.ts +++ b/src/app/core/navigation/navigation.service.ts @@ -108,11 +108,11 @@ export class NavigationService { children: this.inventoryChildrenProperties, }, { - id: 'data', - title: 'Data', + id: 'datasets', + title: 'Datasets', type: 'basic', icon: 'fa-solid:sitemap', - link: '/data', + link: '/datasets', }, { id: 'organizations', @@ -275,7 +275,7 @@ export class NavigationService { this._datasetService.datasetCount$.subscribe((count) => { // Use a timeout to avoid the race condition where mainNavigation hasn't been registered yet setTimeout(() => { - this.updateBadge('data', 'mainNavigation', count) + this.updateBadge('datasets', 'mainNavigation', count) }) }) this.getNavigation() diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index 49bca591..fe4aa02a 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -17,7 +17,7 @@ import { FormModalComponent } from './modal/form-modal.component' @Component({ selector: 'seed-data', - templateUrl: './data.component.html', + templateUrl: './datasets.component.html', encapsulation: ViewEncapsulation.None, // changeDetection: ChangeDetectionStrategy.OnPush, imports: [ @@ -28,13 +28,12 @@ import { FormModalComponent } from './modal/form-modal.component' PageComponent, ], }) -export class DataComponent implements OnInit { +export class DatasetsComponent implements OnInit { private _configService = inject(ConfigService) private _datasetService = inject(DatasetService) private _route = inject(ActivatedRoute) private _router = inject(Router) private _userService = inject(UserService) - readonly sort = viewChild.required(MatSort) private _dialog = inject(MatDialog) columnDefs: ColDef[] datasets: Dataset[] @@ -69,7 +68,7 @@ export class DataComponent implements OnInit { setColumnDefs() { this.columnDefs = [ { field: 'id', hide: true }, - { field: 'name', headerName: 'Name' }, + { field: 'name', headerName: 'Name', cellRenderer: this.nameRenderer }, { field: 'importfiles', headerName: 'Files', valueGetter: ({ data }: { data: Dataset }) => data.importfiles.length }, { field: 'updated_at', headerName: 'Updated At', valueGetter: ({ data }: { data: Dataset }) => new Date(data.updated_at).toLocaleDateString() }, { field: 'last_modified_by', headerName: 'Last Modified By' }, @@ -77,6 +76,15 @@ export class DataComponent implements OnInit { ] } + nameRenderer({ value }: { value: string }) { + return ` +
+ ${value} + open_in_new +
+ ` + } + actionsRenderer() { return `
@@ -97,8 +105,7 @@ export class DataComponent implements OnInit { } onCellClicked(event: CellClickedEvent) { - console.log('cell clicked', event) - if (event.colDef.field !== 'actions') return + if (!['actions', 'name'].includes(event.colDef.field)) return const target = event.event.target as HTMLElement const action = target.closest('[data-action]')?.getAttribute('data-action') @@ -111,6 +118,8 @@ export class DataComponent implements OnInit { this.editDataset(dataset) } else if (action === 'delete') { this.deleteDataset(dataset) + } else if(action === 'detail') { + void this._router.navigate([`/datasets/${id}`]) } } diff --git a/src/app/modules/datasets/datasets.routes.ts b/src/app/modules/datasets/datasets.routes.ts index 1234cfbc..c4e0b2fe 100644 --- a/src/app/modules/datasets/datasets.routes.ts +++ b/src/app/modules/datasets/datasets.routes.ts @@ -3,13 +3,13 @@ import type { Routes } from '@angular/router' import { switchMap, take, tap } from 'rxjs' import { DatasetService } from '@seed/api/dataset' import { UserService } from '@seed/api/user' -import { DataComponent } from './datasets.component' +import { DatasetsComponent } from './datasets.component' export default [ { path: '', title: 'Data', - component: DataComponent, + component: DatasetsComponent, runGuardsAndResolvers: 'always', resolve: { datasets: () => { @@ -21,7 +21,7 @@ export default [ { path: ':id', title: 'TODO', - component: DataComponent, + component: DatasetsComponent, resolve: { data: () => { const datasetService = inject(DatasetService) From 5c3410b1a933a2ffd67444269b7e41b6c426781c Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 26 Jun 2025 15:43:56 +0000 Subject: [PATCH 07/50] dataset grid, dummy actions --- src/@seed/api/dataset/dataset.service.ts | 26 ++- src/@seed/api/dataset/dataset.types.ts | 10 +- .../datasets/dataset/dataset.component.html | 23 +++ .../datasets/dataset/dataset.component.ts | 163 ++++++++++++++++++ src/app/modules/datasets/dataset/index.ts | 1 + .../modules/datasets/datasets.component.html | 2 +- .../modules/datasets/datasets.component.ts | 4 +- src/app/modules/datasets/datasets.routes.ts | 5 +- src/app/modules/datasets/index.ts | 2 + .../notes/notes.component.html | 2 +- 10 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/app/modules/datasets/dataset/dataset.component.html create mode 100644 src/app/modules/datasets/dataset/dataset.component.ts create mode 100644 src/app/modules/datasets/dataset/index.ts create mode 100644 src/app/modules/datasets/index.ts diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index 347a20f2..7882fa76 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -2,9 +2,9 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { catchError, combineLatest, map, of, ReplaySubject, switchMap, tap } from 'rxjs' +import { catchError, map, ReplaySubject, tap } from 'rxjs' import { UserService } from '../user' -import type { CountDatasetsResponse, Dataset, ListDatasetsResponse } from './dataset.types' +import type { CountDatasetsResponse, Dataset, DatasetResponse, ListDatasetsResponse } from './dataset.types' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @@ -39,6 +39,16 @@ export class DatasetService { ).subscribe() } + get(orgId: number, datasetId: number): Observable { + const url = `/api/v3/datasets/${datasetId}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ dataset }) => dataset), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching dataset') + }), + ) + } + create(orgId: number, name: string): Observable { const url = `/api/v3/datasets/?organization_id=${orgId}` return this._httpClient.post(url, { name }).pipe( @@ -86,10 +96,20 @@ export class DatasetService { countDatasets(orgId: number) { this._httpClient.get(`/api/v3/datasets/count/?organization_id=${orgId}`).pipe( map(({ datasets_count }) => datasets_count), - tap((datasetsCount) => { this._datasetCount.next(datasetsCount)}), + tap((datasetsCount) => { this._datasetCount.next(datasetsCount) }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching dataset count') }), ).subscribe() } + + deleteFile(orgId: number, fileId: number) { + const url = `/api/v3/import_files/${fileId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { this._snackBar.success('File deleted successfully') }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting file') + }), + ) + } } diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 83c17f0e..3e5147d8 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -1,14 +1,17 @@ // Subset type -type ImportFile = { +export type ImportFile = { created: string; modified: string; deleted: boolean; import_record: number; cycle: number; + cycle_name?: string; // used in dataset.component ag-grid file: string; uploaded_filename: string; cached_first_row: string; id: number; + source_type: string; + num_rows: number; } // Subset type @@ -39,3 +42,8 @@ export type CountDatasetsResponse = { status: 'success'; datasets_count: number; } + +export type DatasetResponse = { + status: 'success'; + dataset: Dataset; +} diff --git a/src/app/modules/datasets/dataset/dataset.component.html b/src/app/modules/datasets/dataset/dataset.component.html new file mode 100644 index 00000000..ce30f3af --- /dev/null +++ b/src/app/modules/datasets/dataset/dataset.component.html @@ -0,0 +1,23 @@ + +
+ @if (importFiles.length) { + + } +
+
\ No newline at end of file diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts new file mode 100644 index 00000000..b47bf2ca --- /dev/null +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -0,0 +1,163 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { ActivatedRoute } from '@angular/router' +import { Cycle } from '@seed/api/cycle' +import { CycleService } from '@seed/api/cycle/cycle.service' +import type { Dataset, ImportFile } from '@seed/api/dataset' +import { DatasetService } from '@seed/api/dataset' +import { UserService } from '@seed/api/user' +import { DeleteModalComponent, PageComponent } from '@seed/components' +import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' +import { AgGridAngular } from 'ag-grid-angular' +import { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import type { Observable } from 'rxjs' +import { filter, of, Subject, switchMap, tap } from 'rxjs' + +@Component({ + selector: 'seed-dataset', + templateUrl: './dataset.component.html', + imports: [ + AgGridAngular, + CommonModule, + PageComponent, + ], +}) +export class DatasetComponent implements OnDestroy, OnInit { + private _configService = inject(ConfigService) + private _cycleService = inject(CycleService) + private _datasetService = inject(DatasetService) + private _dialog = inject(MatDialog) + private _route = inject(ActivatedRoute) + private _userService = inject(UserService) + private readonly _unsubscribeAll$ = new Subject() + columnDefs: ColDef[] = [] + cycles: Cycle[] = [] + cyclesMap: Record + dataset: Dataset + datasetId = this._route.snapshot.params?.id as number + datasetName$: Observable + importFiles: ImportFile[] = [] + gridApi: GridApi + gridTheme$ = this._configService.gridTheme$ + orgId: number + + ngOnInit(): void { + this._userService.currentOrganizationId$.pipe( + tap((orgId) => { this.orgId = orgId }), + switchMap(() => this.getCycles()), + switchMap(() => this.getDataset()), + ).subscribe() + } + + getCycles() { + return this._cycleService.cycles$.pipe( + tap((cycles) => { + this.cycles = cycles + this.cyclesMap = cycles.reduce((acc, c) => ({ ...acc, [c.id]: c.name }), {}) + console.log('cyclesMap', this.cyclesMap) + }), + ) + } + + getDataset() { + return this._datasetService.get(this.orgId, this.datasetId).pipe( + tap((dataset) => { + this.dataset = dataset + this.formatImportFiles(dataset) + this.datasetName$ = of(dataset.name) + this.setColumnDefs() + }), + ) + } + + formatImportFiles(dataset: Dataset) { + const { importfiles } = dataset + this.importFiles = importfiles.map((f) => ({ ...f, cycle_name: this.cyclesMap[f.cycle] })) + this.importFiles.sort((a, b) => naturalSort(b.created, a.created)) + } + + setColumnDefs() { + this.columnDefs = [ + { field: 'id', hide: true }, + { field: 'uploaded_filename', headerName: 'File Name' }, + { field: 'created', headerName: 'Date Imported', valueFormatter: ({ value }: { value: string }) => new Date(value).toLocaleDateString() }, + { field: 'source_type', headerName: 'Source Type' }, + { field: 'num_rows', headerName: 'Record Count' }, + { field: 'cycle_name', headerName: 'Cycle' }, + { field: 'actions', headerName: 'Actions', cellRenderer: this.actionsRenderer, width: 400, suppressSizeToFit: true }, + ] + } + + actionsRenderer() { + return ` +
+ + add + Data Mapping + + + add + Data Pairing + + cloud_download + clear +
+ ` + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + this.gridApi.sizeColumnsToFit() + this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + } + + onCellClicked(event: CellClickedEvent) { + if (event.colDef.field !== 'actions') return + + const target = event.event.target as HTMLElement + const action = target.closest('[data-action]')?.getAttribute('data-action') + const { id } = event.data as { id: number } + + const importFile = this.importFiles.find((f) => f.id === id) + + if (action === 'delete') { + this.deleteImportFile(importFile) + } else if (action === 'download') { + this.downloadDocument(importFile.file, importFile.uploaded_filename) + } else if (action === 'dataMapping') { + console.log('data mapping', importFile) + } else if (action === 'dataPairing') { + console.log('data pairing', importFile) + } + } + + deleteImportFile(importFile: ImportFile) { + const dialogRef = this._dialog.open(DeleteModalComponent, { + width: '40rem', + data: { model: 'Import File', instance: importFile.uploaded_filename }, + }) + + dialogRef.afterClosed().pipe( + filter(Boolean), + switchMap(() => this._datasetService.deleteFile(this.orgId, importFile.id)), + switchMap(() => this.getDataset()), + ).subscribe() + } + + downloadDocument(file: string, filename: string) { + console.log('file', file) + const a = document.createElement('a') + const url = file + a.href = url + a.download = filename + a.click() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/datasets/dataset/index.ts b/src/app/modules/datasets/dataset/index.ts new file mode 100644 index 00000000..0a65d5ba --- /dev/null +++ b/src/app/modules/datasets/dataset/index.ts @@ -0,0 +1 @@ +export * from './dataset.component' diff --git a/src/app/modules/datasets/datasets.component.html b/src/app/modules/datasets/datasets.component.html index 64b1fcf7..4901d1d5 100644 --- a/src/app/modules/datasets/datasets.component.html +++ b/src/app/modules/datasets/datasets.component.html @@ -7,7 +7,7 @@ actionText: 'Create Dataset', }" > -
+
@if (datasets.length) { { const datasetService = inject(DatasetService) diff --git a/src/app/modules/datasets/index.ts b/src/app/modules/datasets/index.ts new file mode 100644 index 00000000..b949ec84 --- /dev/null +++ b/src/app/modules/datasets/index.ts @@ -0,0 +1,2 @@ +export * from './datasets.component' +export * from './dataset' diff --git a/src/app/modules/inventory-detail/notes/notes.component.html b/src/app/modules/inventory-detail/notes/notes.component.html index a8e1d28e..4e7deb38 100644 --- a/src/app/modules/inventory-detail/notes/notes.component.html +++ b/src/app/modules/inventory-detail/notes/notes.component.html @@ -10,7 +10,7 @@ action: createNote, }" > -
+
@if (rowData.length) { Date: Thu, 26 Jun 2025 16:20:07 +0000 Subject: [PATCH 08/50] icons --- src/app/modules/datasets/dataset/dataset.component.html | 2 +- src/app/modules/datasets/dataset/dataset.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/modules/datasets/dataset/dataset.component.html b/src/app/modules/datasets/dataset/dataset.component.html index ce30f3af..84a6b9f6 100644 --- a/src/app/modules/datasets/dataset/dataset.component.html +++ b/src/app/modules/datasets/dataset/dataset.component.html @@ -15,7 +15,7 @@ [domLayout]="'autoHeight'" [pagination]="true" [paginationPageSizeSelector]="[10, 20, 50, 100]" - [paginationPageSize]="10" + [paginationPageSize]="20" (gridReady)="onGridReady($event)" > } diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index b47bf2ca..da833dce 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -95,12 +95,12 @@ export class DatasetComponent implements OnDestroy, OnInit { return `
- add Data Mapping + open_in_new - add Data Pairing + open_in_new cloud_download clear From 3740ead55b2a7c8036430ebc70cfb487c73cb466 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 26 Jun 2025 22:00:36 +0000 Subject: [PATCH 09/50] basic upload to mapping --- src/@seed/api/dataset/dataset.service.ts | 13 +- src/@seed/api/dataset/dataset.types.ts | 5 + .../data-mappings/data-mapping.component.html | 49 ++++++ .../data-mappings/data-mapping.component.ts | 55 ++++++ .../modules/datasets/data-mappings/index.ts | 1 + .../data-upload-modal.component.html | 25 +++ .../data-upload-modal.component.ts | 38 ++++ .../property-taxlot-upload.component.html | 87 ++++++++++ .../property-taxlot-upload.component.ts | 164 ++++++++++++++++++ .../datasets/dataset/dataset.component.ts | 10 +- .../modules/datasets/datasets.component.ts | 14 +- src/app/modules/datasets/datasets.routes.ts | 6 + src/app/modules/datasets/index.ts | 1 + .../green-button-upload-modal.component.html | 2 +- 14 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 src/app/modules/datasets/data-mappings/data-mapping.component.html create mode 100644 src/app/modules/datasets/data-mappings/data-mapping.component.ts create mode 100644 src/app/modules/datasets/data-mappings/index.ts create mode 100644 src/app/modules/datasets/data-upload/data-upload-modal.component.html create mode 100644 src/app/modules/datasets/data-upload/data-upload-modal.component.ts create mode 100644 src/app/modules/datasets/data-upload/property-taxlot-upload.component.html create mode 100644 src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index 7882fa76..c6adb53b 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' import { catchError, map, ReplaySubject, tap } from 'rxjs' import { UserService } from '../user' -import type { CountDatasetsResponse, Dataset, DatasetResponse, ListDatasetsResponse } from './dataset.types' +import type { CountDatasetsResponse, Dataset, DatasetResponse, ImportFile, ImportFileResponse, ListDatasetsResponse } from './dataset.types' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @@ -112,4 +112,15 @@ export class DatasetService { }), ) } + + getImportFile(orgId: number, fieldId: number): Observable { + const url = `/api/v3/import_files/${fieldId}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + tap((response) => { console.log('temp', response) }), + map(({ import_file }) => import_file), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching import file') + }), + ) + } } diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 3e5147d8..17003a6d 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -47,3 +47,8 @@ export type DatasetResponse = { status: 'success'; dataset: Dataset; } + +export type ImportFileResponse = { + status: 'success'; + import_file: ImportFile; +} diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html new file mode 100644 index 00000000..cd49eca1 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -0,0 +1,49 @@ + +
+ + + + + + + + +
+
+ + + + +
+

+ HelpEmail Templates +

+

Custom Emails

+
+ Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner + Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used + to email users their account information. +
+
+ The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the + latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name. +
+
+ "Your building's latitude and longitude is {{ '{{' }}latitude{{ '}}' }}, {{ '{{' }}longitude{{ '}}' }}!" +
+
+
+ + + + MAPPING! + + \ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts new file mode 100644 index 00000000..ffafeb9d --- /dev/null +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { MatSidenavModule } from '@angular/material/sidenav' +import { ActivatedRoute } from '@angular/router' +import { DatasetService } from '@seed/api/dataset' +import { UserService } from '@seed/api/user' +import { PageComponent } from '@seed/components' +import { AgGridAngular } from 'ag-grid-angular' +import { Subject, switchMap, take, tap } from 'rxjs' + +@Component({ + selector: 'seed-data-mapping', + templateUrl: './data-mapping.component.html', + imports: [ + AgGridAngular, + CommonModule, + MatIconModule, + PageComponent, + MatSidenavModule, + MatButtonModule, + ], +}) +export class DataMappingComponent implements OnDestroy, OnInit { + private readonly _unsubscribeAll$ = new Subject() + private _datasetService = inject(DatasetService) + private _router = inject(ActivatedRoute) + private _userService = inject(UserService) + helpOpened = false + fileId = this._router.snapshot.params.id as number + orgId: number + + ngOnInit(): void { + console.log('Data Mapping Component Initialized') + this._userService.currentOrganizationId$ + .pipe( + take(1), + tap((orgId) => this.orgId = orgId), + switchMap(() => this._datasetService.getImportFile(this.orgId, this.fileId)), + tap((importFile) => { console.log('import file', importFile) }), + ) + .subscribe() + } + + toggleHelp = () => { + this.helpOpened = !this.helpOpened + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/datasets/data-mappings/index.ts b/src/app/modules/datasets/data-mappings/index.ts new file mode 100644 index 00000000..b86d0eb2 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/index.ts @@ -0,0 +1 @@ +export * from './data-mapping.component' diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.html b/src/app/modules/datasets/data-upload/data-upload-modal.component.html new file mode 100644 index 00000000..2bab5be2 --- /dev/null +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -0,0 +1,25 @@ +
+
+
+ +
+ +
+
Upload Property / Tax Lot Data
+
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts new file mode 100644 index 00000000..4e86ac3e --- /dev/null +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -0,0 +1,38 @@ +import { CommonModule } from '@angular/common' +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +import type { Dataset } from '@seed/api/dataset' +import { PropertyTaxlotUploadComponent } from './property-taxlot-upload.component' +import { Cycle } from '@seed/api/cycle' + +@Component({ + selector: 'seed-data-upload-modal', + templateUrl: './data-upload-modal.component.html', + imports: [ + CommonModule, + MatDialogModule, + MatDividerModule, + MatIconModule, + PropertyTaxlotUploadComponent, + ], +}) +export class UploadFileModalComponent implements OnInit { + private _dialogRef = inject(MatDialogRef) + + data = inject(MAT_DIALOG_DATA) as { orgId: number; dataset: Dataset; cycles: Cycle[] } + + ngOnInit() { + return + } + + onSubmit() { + return + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html new file mode 100644 index 00000000..4d3c2806 --- /dev/null +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html @@ -0,0 +1,87 @@ + + + + +
+
+ + +
+ + {{ form.get('multiCycle').value ? 'Default Cycle' : 'Cycle'}} + + + @for (cycle of cycles; track $index) { + {{ cycle.name }} + } + + + + + + Multi-Cycle + +
+ + + + + + +
+
+ + .csv, .xls, .xslx +
+
Note: only the first sheet of multi-sheet Excel files will be imported.
+ + +
+ + .geojson, .json +
+ +
+ + .xml +
+
+ + +
+
+ + @if (uploading) { + + } @else { + +
+ } +
+ + + + @if (inProgress) { + + } + +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts new file mode 100644 index 00000000..64cf80da --- /dev/null +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts @@ -0,0 +1,164 @@ +import { CommonModule } from '@angular/common' +import { HttpErrorResponse } from '@angular/common/http' +import type { ElementRef, OnDestroy, OnInit } from '@angular/core' +import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { MatSelectModule } from '@angular/material/select' +import { MatStepper, MatStepperModule } from '@angular/material/stepper' +import { Router } from '@angular/router' +import { Cycle } from '@seed/api/cycle' +import { CycleService } from '@seed/api/cycle/cycle.service' +import type { Dataset } from '@seed/api/dataset' +import { DatasetService } from '@seed/api/dataset' +import { ProgressResponse } from '@seed/api/progress' +import { ProgressBarComponent } from '@seed/components' +import { ErrorService } from '@seed/services' +import { ProgressBarObj, UploaderService } from '@seed/services/uploader' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { catchError, Subject, switchMap, takeUntil, tap } from 'rxjs' + +@Component({ + selector: 'seed-property-taxlot-upload', + templateUrl: './property-taxlot-upload.component.html', + imports: [ + CommonModule, + MatButtonModule, + MatCheckboxModule, + MatDialogModule, + MatDividerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressBarModule, + MatSelectModule, + MatStepperModule, + ReactiveFormsModule, + ProgressBarComponent, + ], +}) +export class PropertyTaxlotUploadComponent implements OnDestroy, OnInit { + @ViewChild('stepper') stepper!: MatStepper + @ViewChild('fileInput') fileInput: ElementRef + @Input() cycles: Cycle[] + @Input() dataset: Dataset + @Input() orgId: number + @Output() dismissModal = new EventEmitter() + private _datasetService = inject(DatasetService) + private _cycleService = inject(CycleService) + private _uploaderService = inject(UploaderService) + private _errorService = inject(ErrorService) + private _router = inject(Router) + private _snackBar = inject(SnackBarService) + private readonly _unsubscribeAll$ = new Subject() + allowedTypes: string + completed = { 1: false, 2: false } + file: File + fileId: number + inProgress = false + uploading = false + progressBarObj: ProgressBarObj = { + message: [], + progress: 0, + total: 100, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw' + + form = new FormGroup({ + cycleId: new FormControl(null, Validators.required), + multiCycle: new FormControl(false), + }) + + ngOnInit() { + this.form.patchValue({ cycleId: this.cycles[0]?.id }) + } + + step1(fileList: FileList) { + this.file = fileList?.[0] + const cycleId = this.form.get('cycleId')?.value + const multiCycle = this.form.get('multiCycle')?.value + this.uploading = true + + return this._uploaderService + .fileUpload(this.orgId, this.file, this.sourceType, this.dataset.id.toString()) + .pipe( + takeUntil(this._unsubscribeAll$), + tap(({ import_file_id }) => { + this.fileId = import_file_id + this.completed[1] = true + }), + switchMap(() => this._uploaderService.saveRawData(this.orgId, cycleId, this.fileId, multiCycle)), + tap(({ progress_key }: { progress_key: string }) => { + this.uploading = false + this.stepper.next() + this.step2(progress_key) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error uploading file') + }), + ) + .subscribe() + } + + triggerUpload(sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw') { + this.sourceType = sourceType + const allowedMap = { + 'Assessed Raw': '.csv,.xls,.xlsx', + GeoJSON: '.geojson,application/geo+json', + 'BuildingSync Raw': '.xml,application/xml,text/xml', + } + this.allowedTypes = allowedMap[sourceType] + + setTimeout(() => { + this.fileInput.nativeElement.click() + }) + } + + step2(progressKey: string) { + this.inProgress = true + + const failureFn = () => { + this._snackBar.alert('File Upload Failed') + } + + const successFn = () => { + this._snackBar.success('Successfully uploaded file') + console.log(this.progressBarObj) + this.dismissModal.emit() + + void this._router.navigate(['/datasets/mappings', this.fileId]) + } + + this._uploaderService + .checkProgressLoop({ + progressKey, + offset: 0, + multiplier: 1, + failureFn, + successFn, + progressBarObj: this.progressBarObj, + }) + .subscribe() + } + + onSubmit() { + console.log('onSubmit') + return + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index da833dce..5e4bfdea 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -3,7 +3,11 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MatDialog } from '@angular/material/dialog' import { ActivatedRoute } from '@angular/router' -import { Cycle } from '@seed/api/cycle' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import type { Observable } from 'rxjs' +import { filter, of, Subject, switchMap, tap } from 'rxjs' +import type { Cycle } from '@seed/api/cycle' import { CycleService } from '@seed/api/cycle/cycle.service' import type { Dataset, ImportFile } from '@seed/api/dataset' import { DatasetService } from '@seed/api/dataset' @@ -11,10 +15,6 @@ import { UserService } from '@seed/api/user' import { DeleteModalComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { naturalSort } from '@seed/utils' -import { AgGridAngular } from 'ag-grid-angular' -import { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import type { Observable } from 'rxjs' -import { filter, of, Subject, switchMap, tap } from 'rxjs' @Component({ selector: 'seed-dataset', diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index 4f73dc1f..b56712c4 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common' import type { OnInit } from '@angular/core' -import { Component, inject, viewChild, ViewEncapsulation } from '@angular/core' +import { Component, inject, ViewEncapsulation } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MatDialog } from '@angular/material/dialog' import { MatIconModule } from '@angular/material/icon' @@ -14,6 +14,9 @@ import { DeleteModalComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { naturalSort } from '@seed/utils' import { FormModalComponent } from './modal/form-modal.component' +import { UploadFileModalComponent } from './data-upload/data-upload-modal.component' +import { CycleService } from '@seed/api/cycle/cycle.service' +import { Cycle } from '@seed/api/cycle' @Component({ selector: 'seed-data', @@ -30,12 +33,14 @@ import { FormModalComponent } from './modal/form-modal.component' }) export class DatasetsComponent implements OnInit { private _configService = inject(ConfigService) + private _cycleService = inject(CycleService) private _datasetService = inject(DatasetService) private _route = inject(ActivatedRoute) private _router = inject(Router) private _userService = inject(UserService) private _dialog = inject(MatDialog) columnDefs: ColDef[] + cycles: Cycle[] = [] datasets: Dataset[] datasetsColumns = ['name', 'importfiles', 'updated_at', 'last_modified_by', 'actions'] existingNames: string[] = [] @@ -56,6 +61,8 @@ export class DatasetsComponent implements OnInit { this.orgId = orgId this._datasetService.list(orgId) }), + switchMap(() => this._cycleService.cycles$), + tap((cycles) => { this.cycles = cycles }), switchMap(() => this._datasetService.datasets$), tap((datasets) => { this.datasets = datasets.sort((a, b) => naturalSort(a.name, b.name)) @@ -113,7 +120,10 @@ export class DatasetsComponent implements OnInit { const dataset = this.datasets.find((ds) => ds.id === id) if (action === 'addDataFiles') { - console.log('add data files', dataset) + this._dialog.open(UploadFileModalComponent, { + width: '40rem', + data: { orgId: this.orgId, dataset, cycles: this.cycles }, + }) } else if (action === 'rename') { this.editDataset(dataset) } else if (action === 'delete') { diff --git a/src/app/modules/datasets/datasets.routes.ts b/src/app/modules/datasets/datasets.routes.ts index 3c3fd32e..408068e7 100644 --- a/src/app/modules/datasets/datasets.routes.ts +++ b/src/app/modules/datasets/datasets.routes.ts @@ -3,6 +3,7 @@ import type { Routes } from '@angular/router' import { switchMap, take, tap } from 'rxjs' import { DatasetService } from '@seed/api/dataset' import { UserService } from '@seed/api/user' +import { DataMappingComponent } from './data-mappings' import { DatasetComponent } from './dataset/dataset.component' import { DatasetsComponent } from './datasets.component' @@ -36,4 +37,9 @@ export default [ }, }, }, + { + path: 'mappings/:id', + title: 'Data Mappings', + component: DataMappingComponent, + }, ] satisfies Routes diff --git a/src/app/modules/datasets/index.ts b/src/app/modules/datasets/index.ts index b949ec84..7a4e3855 100644 --- a/src/app/modules/datasets/index.ts +++ b/src/app/modules/datasets/index.ts @@ -1,2 +1,3 @@ export * from './datasets.component' export * from './dataset' +export * from './data-mappings' \ No newline at end of file diff --git a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html index 49a2faad..c4c8858a 100644 --- a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html +++ b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html @@ -89,7 +89,7 @@ } From ac8e658753aea307aca7bc7f75a596cbe4123430 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 27 Jun 2025 16:24:23 +0000 Subject: [PATCH 10/50] ptl data upload modal fxnal --- src/app/app.routes.ts | 2 +- src/app/core/navigation/navigation.service.ts | 8 +-- .../property-taxlot-upload.component.html | 37 +++++++++----- .../property-taxlot-upload.component.ts | 49 +++++++++++++------ .../modules/datasets/datasets.component.ts | 2 +- 5 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index bd6911db..c8bb6569 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -72,7 +72,7 @@ export const appRoutes: Route[] = [ loadChildren: () => import('app/modules/inventory/inventory.routes'), }, { - path: 'datasets', + path: 'data', loadChildren: () => import('app/modules/datasets/datasets.routes'), }, { path: 'documentation', title: 'Documentation', component: DocumentationComponent }, diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts index 5c73cdaf..32b6d0b2 100644 --- a/src/app/core/navigation/navigation.service.ts +++ b/src/app/core/navigation/navigation.service.ts @@ -108,11 +108,11 @@ export class NavigationService { children: this.inventoryChildrenProperties, }, { - id: 'datasets', - title: 'Datasets', + id: 'data', + title: 'Data', type: 'basic', icon: 'fa-solid:sitemap', - link: '/datasets', + link: '/data', }, { id: 'organizations', @@ -275,7 +275,7 @@ export class NavigationService { this._datasetService.datasetCount$.subscribe((count) => { // Use a timeout to avoid the race condition where mainNavigation hasn't been registered yet setTimeout(() => { - this.updateBadge('datasets', 'mainNavigation', count) + this.updateBadge('data', 'mainNavigation', count) }) }) this.getNavigation() diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html index 4d3c2806..b7630e0b 100644 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html @@ -1,6 +1,6 @@ - - + +
@@ -74,14 +74,27 @@ } - - - @if (inProgress) { - - } - + + + @if (inProgress) { + + } + + + + + +
+ + +
+
+ \ No newline at end of file diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts index 64cf80da..b6373cbf 100644 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts @@ -1,6 +1,7 @@ +import { StepperSelectionEvent } from '@angular/cdk/stepper' import { CommonModule } from '@angular/common' import { HttpErrorResponse } from '@angular/common/http' -import type { ElementRef, OnDestroy, OnInit } from '@angular/core' +import type { AfterViewInit, ElementRef, OnDestroy, OnInit } from '@angular/core' import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core' import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' @@ -13,7 +14,7 @@ import { MatInputModule } from '@angular/material/input' import { MatProgressBarModule } from '@angular/material/progress-bar' import { MatSelectModule } from '@angular/material/select' import { MatStepper, MatStepperModule } from '@angular/material/stepper' -import { Router } from '@angular/router' +import { Router, RouterModule } from '@angular/router' import { Cycle } from '@seed/api/cycle' import { CycleService } from '@seed/api/cycle/cycle.service' import type { Dataset } from '@seed/api/dataset' @@ -40,11 +41,12 @@ import { catchError, Subject, switchMap, takeUntil, tap } from 'rxjs' MatProgressBarModule, MatSelectModule, MatStepperModule, - ReactiveFormsModule, ProgressBarComponent, + ReactiveFormsModule, + RouterModule, ], }) -export class PropertyTaxlotUploadComponent implements OnDestroy, OnInit { +export class PropertyTaxlotUploadComponent implements AfterViewInit, OnDestroy { @ViewChild('stepper') stepper!: MatStepper @ViewChild('fileInput') fileInput: ElementRef @Input() cycles: Cycle[] @@ -59,7 +61,7 @@ export class PropertyTaxlotUploadComponent implements OnDestroy, OnInit { private _snackBar = inject(SnackBarService) private readonly _unsubscribeAll$ = new Subject() allowedTypes: string - completed = { 1: false, 2: false } + completed = { 1: false, 2: false, 3: false } file: File fileId: number inProgress = false @@ -79,8 +81,8 @@ export class PropertyTaxlotUploadComponent implements OnDestroy, OnInit { cycleId: new FormControl(null, Validators.required), multiCycle: new FormControl(false), }) - - ngOnInit() { + + ngAfterViewInit() { this.form.patchValue({ cycleId: this.cycles[0]?.id }) } @@ -133,11 +135,11 @@ export class PropertyTaxlotUploadComponent implements OnDestroy, OnInit { } const successFn = () => { + this.completed[2] = true this._snackBar.success('Successfully uploaded file') - console.log(this.progressBarObj) - this.dismissModal.emit() - - void this._router.navigate(['/datasets/mappings', this.fileId]) + setTimeout(() => { + this.stepper.next() + }) } this._uploaderService @@ -152,9 +154,28 @@ export class PropertyTaxlotUploadComponent implements OnDestroy, OnInit { .subscribe() } - onSubmit() { - console.log('onSubmit') - return + goToMapping() { + this.dismissModal.emit() + void this._router.navigate(['/data/mappings', this.fileId]) + } + + goToStep1() { + this.completed[3] = true + this.stepper.selectedIndex = 0 + } + + onStepChange(event: StepperSelectionEvent) { + const index = event.selectedIndex + if (index === 0) this.resetStepper() + } + + resetStepper() { + this.completed = { 1: false, 2: false, 3: false } + this.file = null + this.fileId = null + this.fileInput.nativeElement.value = '' + this.inProgress = false + this.uploading = false } ngOnDestroy(): void { diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index b56712c4..9d1acba2 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -129,7 +129,7 @@ export class DatasetsComponent implements OnInit { } else if (action === 'delete') { this.deleteDataset(dataset) } else if (action === 'detail') { - void this._router.navigate([`/datasets/${id}`]) + void this._router.navigate([`/data/${id}`]) } } From afcb9e31e9fd4f9aec900cd6776e02e989b98871 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 27 Jun 2025 16:41:18 +0000 Subject: [PATCH 11/50] import data to mapping --- src/@seed/api/mapping/index.ts | 2 + src/@seed/api/mapping/mapping.service.ts | 43 ++++++++++++++ src/@seed/api/mapping/mapping.types.ts | 18 ++++++ .../data-mappings/data-mapping.component.ts | 42 ++++++++++++-- .../data-mappings/help.component.html | 57 +++++++++++++++++++ .../datasets/data-mappings/help.component.ts | 0 6 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/@seed/api/mapping/index.ts create mode 100644 src/@seed/api/mapping/mapping.service.ts create mode 100644 src/@seed/api/mapping/mapping.types.ts create mode 100644 src/app/modules/datasets/data-mappings/help.component.html create mode 100644 src/app/modules/datasets/data-mappings/help.component.ts diff --git a/src/@seed/api/mapping/index.ts b/src/@seed/api/mapping/index.ts new file mode 100644 index 00000000..8882aa58 --- /dev/null +++ b/src/@seed/api/mapping/index.ts @@ -0,0 +1,2 @@ +export * from './mapping.service' +export * from './mapping.types' diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts new file mode 100644 index 00000000..5ad9657b --- /dev/null +++ b/src/@seed/api/mapping/mapping.service.ts @@ -0,0 +1,43 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { ErrorService } from '@seed/services' +import { UserService } from '../user' +import { catchError, type Observable } from 'rxjs' +import type { FirstFiveRowsResponse, MappingSuggestionsResponse, RawColumnNamesResponse } from './mapping.types' + +@Injectable({ providedIn: 'root' }) +export class MappingService { + private _httpClient = inject(HttpClient) + private _errorService = inject(ErrorService) + private _userService = inject(UserService) + + mappingSuggestions(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/mapping_suggestions/?organization_id=${orgId}` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching mapping suggestions') + }), + ) + } + + rawColumnNames(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/raw_column_names/?organization_id=${orgId}` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching raw column names') + }), + ) + } + + firstFiveRows(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/first_five_rows/?organization_id=${orgId}` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching first five rows') + }), + ) + } +} diff --git a/src/@seed/api/mapping/mapping.types.ts b/src/@seed/api/mapping/mapping.types.ts new file mode 100644 index 00000000..45b8fad3 --- /dev/null +++ b/src/@seed/api/mapping/mapping.types.ts @@ -0,0 +1,18 @@ +import type { Column } from '../column' + +export type MappingSuggestionsResponse = { + status: string; + property_columns: Column[]; + suggested_column_mappings: Record; + taxlot_columns: Column[]; +} + +export type RawColumnNamesResponse = { + status: string; + raw_columns: string[]; +} + +export type FirstFiveRowsResponse = { + status: string; + first_five_rows: Record[]; +} diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index ffafeb9d..3f467a34 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -5,11 +5,12 @@ import { MatButtonModule } from '@angular/material/button' import { MatIconModule } from '@angular/material/icon' import { MatSidenavModule } from '@angular/material/sidenav' import { ActivatedRoute } from '@angular/router' -import { DatasetService } from '@seed/api/dataset' +import { DatasetService, ImportFile } from '@seed/api/dataset' +import { FirstFiveRowsResponse, MappingService, MappingSuggestionsResponse, RawColumnNamesResponse } from '@seed/api/mapping' import { UserService } from '@seed/api/user' import { PageComponent } from '@seed/components' import { AgGridAngular } from 'ag-grid-angular' -import { Subject, switchMap, take, tap } from 'rxjs' +import { catchError, filter, forkJoin, of, Subject, switchMap, take, tap } from 'rxjs' @Component({ selector: 'seed-data-mapping', @@ -26,11 +27,16 @@ import { Subject, switchMap, take, tap } from 'rxjs' export class DataMappingComponent implements OnDestroy, OnInit { private readonly _unsubscribeAll$ = new Subject() private _datasetService = inject(DatasetService) + private _mappingService = inject(MappingService) private _router = inject(ActivatedRoute) private _userService = inject(UserService) helpOpened = false fileId = this._router.snapshot.params.id as number + importFile: ImportFile orgId: number + firstFiveRows: FirstFiveRowsResponse + mappingSuggestions: MappingSuggestionsResponse + rawColumnNames: RawColumnNamesResponse ngOnInit(): void { console.log('Data Mapping Component Initialized') @@ -38,12 +44,40 @@ export class DataMappingComponent implements OnDestroy, OnInit { .pipe( take(1), tap((orgId) => this.orgId = orgId), - switchMap(() => this._datasetService.getImportFile(this.orgId, this.fileId)), - tap((importFile) => { console.log('import file', importFile) }), + switchMap(() => this.getImportFile()), + filter(Boolean), + switchMap(() => this.getMappingData()), ) .subscribe() } + getImportFile() { + return this._datasetService.getImportFile(this.orgId, this.fileId) + .pipe( + take(1), + tap((importFile) => { this.importFile = importFile }), + catchError(() => { + console.log('bad importfile') + return of(null) + }), + ) + } + getMappingData() { + return forkJoin([ + this._mappingService.firstFiveRows(this.orgId, this.fileId), + this._mappingService.mappingSuggestions(this.orgId, this.fileId), + this._mappingService.rawColumnNames(this.orgId, this.fileId), + ]) + .pipe( + take(1), + tap(([firstFiveRows, mappingSuggestions, rawColumnNames]) => { + this.firstFiveRows = firstFiveRows + this.mappingSuggestions = mappingSuggestions + this.rawColumnNames = rawColumnNames + }), + ) + } + toggleHelp = () => { this.helpOpened = !this.helpOpened } diff --git a/src/app/modules/datasets/data-mappings/help.component.html b/src/app/modules/datasets/data-mappings/help.component.html new file mode 100644 index 00000000..a0868911 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/help.component.html @@ -0,0 +1,57 @@ +
+
+ MAPPING YOUR DATA TO SEED +
+ +
+ It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to + type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as + well as typing in the field name from the original datafile. +
+ +
+ In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will + affect how the data is matched and merged, as well as how it is displayed in the Inventory view. +
+ +
+ Column Mapping Profiles can be used to help you easily and consistently map your data. Note that file header columns + defined in the profile must match exactly (spaces, lowercase, uppercase, etc.) in order for the corresponding SEED + column information to be used. +
+ +
+ Field names for matching Properties: Custom ID 1, PM Property ID + +
+ +
+ Field names for matching Tax Lots: Custom ID 1, Jurisdiction Tax Lot ID + +
+ +
+ If there are fields in the datafile mapped to these names, the program will attempt to match on the corresponding values + in existing records. All of these fields must have the same values between records for the records to match. + +
+ +
+ Matches within the same cycle will be merged together, while matches in different cycles will be associated for + cross-cycle analysis. +
+ +
+ When you click the Map Your Data button, the program will show a grid with the new field names as the column headings + and your data in the rows. In that view, you can still come back to the initial mapping screen and change the field + mapping. +
+ +
+ + + + + + + diff --git a/src/app/modules/datasets/data-mappings/help.component.ts b/src/app/modules/datasets/data-mappings/help.component.ts new file mode 100644 index 00000000..e69de29b From c7a99b4d6772d4b76a4deb72188db34366561e7b Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 27 Jun 2025 17:21:26 +0000 Subject: [PATCH 12/50] help component --- .../data-mappings/help.component.html | 20 +++++++++---------- .../datasets/data-mappings/help.component.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/app/modules/datasets/data-mappings/help.component.html b/src/app/modules/datasets/data-mappings/help.component.html index a0868911..4727e294 100644 --- a/src/app/modules/datasets/data-mappings/help.component.html +++ b/src/app/modules/datasets/data-mappings/help.component.html @@ -1,47 +1,45 @@ -
+
MAPPING YOUR DATA TO SEED
-
+
It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name from the original datafile.
-
+
In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.
-
+
Column Mapping Profiles can be used to help you easily and consistently map your data. Note that file header columns defined in the profile must match exactly (spaces, lowercase, uppercase, etc.) in order for the corresponding SEED column information to be used.
-
+
Field names for matching Properties: Custom ID 1, PM Property ID
-
+
Field names for matching Tax Lots: Custom ID 1, Jurisdiction Tax Lot ID -
-
+
If there are fields in the datafile mapped to these names, the program will attempt to match on the corresponding values in existing records. All of these fields must have the same values between records for the records to match. -
-
+
Matches within the same cycle will be merged together, while matches in different cycles will be associated for cross-cycle analysis.
-
+
When you click the Map Your Data button, the program will show a grid with the new field names as the column headings and your data in the rows. In that view, you can still come back to the initial mapping screen and change the field mapping. diff --git a/src/app/modules/datasets/data-mappings/help.component.ts b/src/app/modules/datasets/data-mappings/help.component.ts index e69de29b..a6c633e7 100644 --- a/src/app/modules/datasets/data-mappings/help.component.ts +++ b/src/app/modules/datasets/data-mappings/help.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'seed-data-mapping-help', + templateUrl: './help.component.html', + imports: [], +}) +export class HelpComponent { +} From e0fc3fc7cab5938d964f62cb50acdcd8acbfc31e Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 27 Jun 2025 21:59:06 +0000 Subject: [PATCH 13/50] mapping table dev --- src/@seed/api/cycle/cycle.service.ts | 31 +++- src/@seed/api/dataset/dataset.service.ts | 1 - src/@seed/api/mapping/mapping.service.ts | 8 +- src/@seed/api/mapping/mapping.types.ts | 4 +- .../data-mappings/data-mapping.component.html | 56 +++--- .../data-mappings/data-mapping.component.ts | 164 ++++++++++++++++-- .../datasets/dataset/dataset.component.ts | 4 +- .../sensors/sensors.component.ts | 3 - .../list/inventory.component.ts | 2 +- src/main.ts | 6 +- 10 files changed, 227 insertions(+), 52 deletions(-) diff --git a/src/@seed/api/cycle/cycle.service.ts b/src/@seed/api/cycle/cycle.service.ts index 6c6b02e1..31544f91 100644 --- a/src/@seed/api/cycle/cycle.service.ts +++ b/src/@seed/api/cycle/cycle.service.ts @@ -7,11 +7,12 @@ import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { Cycle, CycleResponse, CyclesResponse } from './cycle.types' +import { UserService } from '../user' @Injectable({ providedIn: 'root' }) export class CycleService { private _httpClient = inject(HttpClient) - private _organizationService = inject(OrganizationService) + private _userService = inject(UserService) private _snackBar = inject(SnackBarService) private _errorService = inject(ErrorService) private _cycles = new BehaviorSubject([]) @@ -20,21 +21,20 @@ export class CycleService { cycles$ = this._cycles.asObservable() constructor() { - this._organizationService.currentOrganization$ + this._userService.currentOrganizationId$ .pipe( - tap(({ org_id }) => { - this.get(org_id) + tap((orgId) => { + this.getCycles(orgId) }), ) .subscribe() } - get(orgId: number) { + getCycles(orgId: number) { const url = `/api/v3/cycles/?organization_id=${orgId}` this._httpClient .get(url) .pipe( - take(1), map(({ cycles }) => cycles), tap((cycles) => { this._cycles.next(cycles) @@ -46,12 +46,25 @@ export class CycleService { .subscribe() } + getCycle(orgId: number, cycleId: number): Observable { + const url = `/api/v3/cycles/${cycleId}?organization_id=${orgId}` + return this._httpClient + .get(url) + .pipe( + take(1), + map(({ cycles }) => cycles), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching cycles') + }), + ) + } + post({ data, orgId }): Observable { const url = `/api/v3/cycles/?organization_id=${orgId}` return this._httpClient.post(url, data).pipe( tap((response) => { this._snackBar.success(`Created Cycle ${response.cycles.name}`) - this.get(orgId as number) + this.getCycles(orgId as number) }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error creating cycle') @@ -64,7 +77,7 @@ export class CycleService { return this._httpClient.put(url, data).pipe( tap((response) => { this._snackBar.success(`Updated Cycle ${response.cycles.name}`) - this.get(orgId as number) + this.getCycles(orgId as number) }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating cycle') @@ -76,7 +89,7 @@ export class CycleService { const url = `/api/v3/cycles/${id}/?organization_id=${orgId}` return this._httpClient.delete(url).pipe( tap(() => { - this.get(orgId) + this.getCycles(orgId) }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error deleting cycle') diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index c6adb53b..440f2dd5 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -116,7 +116,6 @@ export class DatasetService { getImportFile(orgId: number, fieldId: number): Observable { const url = `/api/v3/import_files/${fieldId}/?organization_id=${orgId}` return this._httpClient.get(url).pipe( - tap((response) => { console.log('temp', response) }), map(({ import_file }) => import_file), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching import file') diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index 5ad9657b..ccfb7ac3 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import { ErrorService } from '@seed/services' import { UserService } from '../user' -import { catchError, type Observable } from 'rxjs' +import { catchError, map, type Observable } from 'rxjs' import type { FirstFiveRowsResponse, MappingSuggestionsResponse, RawColumnNamesResponse } from './mapping.types' @Injectable({ providedIn: 'root' }) @@ -21,20 +21,22 @@ export class MappingService { ) } - rawColumnNames(orgId: number, importFileId: number): Observable { + rawColumnNames(orgId: number, importFileId: number): Observable { const url = `/api/v3/import_files/${importFileId}/raw_column_names/?organization_id=${orgId}` return this._httpClient.get(url) .pipe( + map(({ raw_columns }) => raw_columns ), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching raw column names') }), ) } - firstFiveRows(orgId: number, importFileId: number): Observable { + firstFiveRows(orgId: number, importFileId: number): Observable[]> { const url = `/api/v3/import_files/${importFileId}/first_five_rows/?organization_id=${orgId}` return this._httpClient.get(url) .pipe( + map(({ first_five_rows }) => first_five_rows), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching first five rows') }), diff --git a/src/@seed/api/mapping/mapping.types.ts b/src/@seed/api/mapping/mapping.types.ts index 45b8fad3..170a7d78 100644 --- a/src/@seed/api/mapping/mapping.types.ts +++ b/src/@seed/api/mapping/mapping.types.ts @@ -3,10 +3,12 @@ import type { Column } from '../column' export type MappingSuggestionsResponse = { status: string; property_columns: Column[]; - suggested_column_mappings: Record; + suggested_column_mappings: SuggestedColumnMapping; taxlot_columns: Column[]; } +export type SuggestedColumnMapping = Record + export type RawColumnNamesResponse = { status: string; raw_columns: string[]; diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index cd49eca1..1c7a7c64 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -9,7 +9,7 @@ >
- + @@ -22,28 +22,44 @@ -
-

- HelpEmail Templates -

-

Custom Emails

-
- Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner - Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used - to email users their account information. -
-
- The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the - latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name. -
-
- "Your building's latitude and longitude is {{ '{{' }}latitude{{ '}}' }}, {{ '{{' }}longitude{{ '}}' }}!" -
-
+
- MAPPING! + +
+
Cycle
+
{{ cycle?.name }}
+
+
+
Column Profile
+
none selected
+
+ + +
+ + +
+ Set all fields to + + Property + Tax Lot + +
+
+ + + + +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index 3f467a34..fb2b0080 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -5,12 +5,25 @@ import { MatButtonModule } from '@angular/material/button' import { MatIconModule } from '@angular/material/icon' import { MatSidenavModule } from '@angular/material/sidenav' import { ActivatedRoute } from '@angular/router' -import { DatasetService, ImportFile } from '@seed/api/dataset' -import { FirstFiveRowsResponse, MappingService, MappingSuggestionsResponse, RawColumnNamesResponse } from '@seed/api/mapping' -import { UserService } from '@seed/api/user' -import { PageComponent } from '@seed/components' import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef, ColGroupDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' import { catchError, filter, forkJoin, of, Subject, switchMap, take, tap } from 'rxjs' +import type { ImportFile } from '@seed/api/dataset' +import { DatasetService } from '@seed/api/dataset' +import type { MappingSuggestionsResponse } from '@seed/api/mapping' +import { MappingService } from '@seed/api/mapping' +import { UserService } from '@seed/api/user' +import { PageComponent } from '@seed/components' +import { ConfigService } from '@seed/services' +import { HelpComponent } from './help.component' +import { Column } from '@seed/api/column' +import { Cycle } from '@seed/api/cycle' +import { CycleService } from '@seed/api/cycle/cycle.service' +import { InventoryDisplayType, Profile } from 'app/modules/inventory' +import { InventoryService } from '@seed/api/inventory' +import { MatDividerModule } from '@angular/material/divider' +import { MatSelectModule } from '@angular/material/select' + @Component({ selector: 'seed-data-mapping', @@ -18,25 +31,43 @@ import { catchError, filter, forkJoin, of, Subject, switchMap, take, tap } from imports: [ AgGridAngular, CommonModule, + HelpComponent, + MatButtonModule, + MatDividerModule, MatIconModule, - PageComponent, MatSidenavModule, - MatButtonModule, + MatSelectModule, + PageComponent, ], }) export class DataMappingComponent implements OnDestroy, OnInit { private readonly _unsubscribeAll$ = new Subject() + private _configService = inject(ConfigService) + private _cycleService = inject(CycleService) private _datasetService = inject(DatasetService) + private _inventoryService = inject(InventoryService) private _mappingService = inject(MappingService) private _router = inject(ActivatedRoute) private _userService = inject(UserService) - helpOpened = false + columns: Column[] + columnDefs: ColDef[] + currentProfile: Profile + cycle: Cycle + defaultInventoryType = 'Property' fileId = this._router.snapshot.params.id as number + firstFiveRows: Record[] + helpOpened = false importFile: ImportFile - orgId: number - firstFiveRows: FirstFiveRowsResponse + gridApi: GridApi + gridOptions = { + singleClickEdit: true, + suppressMovableColumns: true, + } + gridTheme$ = this._configService.gridTheme$ mappingSuggestions: MappingSuggestionsResponse - rawColumnNames: RawColumnNamesResponse + orgId: number + rawColumnNames: string[] = [] + rowData: Record[] = [] ngOnInit(): void { console.log('Data Mapping Component Initialized') @@ -47,6 +78,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { switchMap(() => this.getImportFile()), filter(Boolean), switchMap(() => this.getMappingData()), + tap(() => { this.setGrid() }), ) .subscribe() } @@ -57,20 +89,21 @@ export class DataMappingComponent implements OnDestroy, OnInit { take(1), tap((importFile) => { this.importFile = importFile }), catchError(() => { - console.log('bad importfile') return of(null) }), ) } getMappingData() { return forkJoin([ + this._cycleService.getCycle(this.orgId, this.importFile.cycle), this._mappingService.firstFiveRows(this.orgId, this.fileId), this._mappingService.mappingSuggestions(this.orgId, this.fileId), this._mappingService.rawColumnNames(this.orgId, this.fileId), ]) .pipe( take(1), - tap(([firstFiveRows, mappingSuggestions, rawColumnNames]) => { + tap(([cycle, firstFiveRows, mappingSuggestions, rawColumnNames]) => { + this.cycle = cycle this.firstFiveRows = firstFiveRows this.mappingSuggestions = mappingSuggestions this.rawColumnNames = rawColumnNames @@ -78,6 +111,113 @@ export class DataMappingComponent implements OnDestroy, OnInit { ) } + setGrid() { + this.setColumnDefs() + this.setRowData() + } + + setColumnDefs() { + const seedCols: ColDef[] = [ + { + field: 'omit', + headerName: 'Omit', + editable: true, + cellEditor: 'agCheckboxCellEditor', + }, + { + field: 'inventory_type', + headerName: 'Inventory Type', + editable: true, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { + values: ['Property', 'Tax Lot'], + }, + cellRenderer: this.dropdownRenderer, + }, + { + field: 'seed_header', + headerName: 'SEED Header', + editable: true, + cellRenderer: this.inputRenderer, + cellEditor: 'agTextCellEditor', + }, + ] + + const fileCols: ColDef[] = [ + { field: 'data_type', headerName: 'Data Type' }, + { field: 'units', headerName: 'Units' }, + { field: 'file_header', headerName: 'Data File Header' }, + { field: 'row1', headerName: 'Row 1' }, + { field: 'row2', headerName: 'Row 2' }, + { field: 'row3', headerName: 'Row 3' }, + { field: 'row4', headerName: 'Row 4' }, + { field: 'row5', headerName: 'Row 5' }, + ] + + this.columnDefs = [ + { headerName: 'SEED', children: seedCols } as ColGroupDef, + { headerName: this.importFile.uploaded_filename, children: fileCols } as ColGroupDef, + ] + } + + dropdownRenderer({ value }: { value: string }) { + return ` +
+ ${value ?? ''} + arrow_drop_down +
+ ` + } + + inputRenderer({ value }: { value: string }) { + return ` +
+ ${value ?? ''} +
+ ` + } + + setRowData() { + this.rowData = [] + + // transpose first 5 rows to fit into the grid + for (const header of this.rawColumnNames) { + const keys = ['file_header', 'row1', 'row2', 'row3', 'row4', 'row5'] + const values = this.firstFiveRows.map((r) => r[header]) + values.unshift(header) + + const data = Object.fromEntries(keys.map((k, i) => [k, values[i]])) + this.rowData.push(data) + } + + for (const row of this.rowData) { + row.omit = false + } + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + // this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + } + + setAllInventoryType(value: InventoryDisplayType) { + this.defaultInventoryType = value + this.gridApi.forEachNode((n) => n.setDataValue('inventory_type', value)) + } + + copyHeadersToSeed() { + const { property_columns, taxlot_columns, suggested_column_mappings } = this.mappingSuggestions + const columns = this.defaultInventoryType === 'Tax Lot' ? taxlot_columns : property_columns + const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) + + this.gridApi.forEachNode((n: RowNode<{ file_header: string }>) => { + const fileHeader = n.data.file_header + const suggestedColumnName = suggested_column_mappings[fileHeader][1] + const displayName = columnMap[suggestedColumnName] + n.setDataValue('seed_header', displayName) + }) + } + toggleHelp = () => { this.helpOpened = !this.helpOpened } diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 5e4bfdea..480108eb 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MatDialog } from '@angular/material/dialog' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { Observable } from 'rxjs' @@ -31,6 +31,7 @@ export class DatasetComponent implements OnDestroy, OnInit { private _datasetService = inject(DatasetService) private _dialog = inject(MatDialog) private _route = inject(ActivatedRoute) + private _router = inject(Router) private _userService = inject(UserService) private readonly _unsubscribeAll$ = new Subject() columnDefs: ColDef[] = [] @@ -128,6 +129,7 @@ export class DatasetComponent implements OnDestroy, OnInit { } else if (action === 'download') { this.downloadDocument(importFile.file, importFile.uploaded_filename) } else if (action === 'dataMapping') { + void this._router.navigate(['/data/mappings/', importFile.id]) console.log('data mapping', importFile) } else if (action === 'dataPairing') { console.log('data pairing', importFile) diff --git a/src/app/modules/inventory-detail/sensors/sensors.component.ts b/src/app/modules/inventory-detail/sensors/sensors.component.ts index 76351d01..c66bf664 100644 --- a/src/app/modules/inventory-detail/sensors/sensors.component.ts +++ b/src/app/modules/inventory-detail/sensors/sensors.component.ts @@ -85,9 +85,6 @@ export class SensorsComponent implements OnDestroy, OnInit { tap((orgId) => { this.orgId = orgId }), - tap(() => { - this._cycleService.get(this.orgId) - }), switchMap(() => this._cycleService.cycles$), tap((cycles) => { this.cycleId = cycles.length ? cycles[0].id : null }), switchMap(() => this._datasetService.datasets$), diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 5c188bf0..508e3b07 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -151,7 +151,7 @@ export class InventoryComponent implements OnDestroy, OnInit { */ getDependencies(org_id: number) { this.orgId = org_id - this._cycleService.get(this.orgId) + // this._cycleService.getCycles(this.orgId) return combineLatest([ this._userService.currentUser$, diff --git a/src/main.ts b/src/main.ts index 14c1329d..9add0539 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,19 @@ import { bootstrapApplication } from '@angular/platform-browser' -import { ClientSideRowModelModule, ColumnAutoSizeModule, EventApiModule, ModuleRegistry, PaginationModule, ValidationModule } from 'ag-grid-community' +import { CheckboxEditorModule, ClientSideRowModelModule, ColumnAutoSizeModule, EventApiModule, ModuleRegistry, PaginationModule, RowApiModule, SelectEditorModule, TextEditorModule, ValidationModule } from 'ag-grid-community' import { AppComponent } from 'app/app.component' import { appConfig } from 'app/app.config' // TEMP - should be overwritten when analyses pr is merged ModuleRegistry.registerModules([ + CheckboxEditorModule, ClientSideRowModelModule, ColumnAutoSizeModule, EventApiModule, PaginationModule, + RowApiModule, + SelectEditorModule, + TextEditorModule, ValidationModule, ]) From 1b9c0fb1e129f22be5f6cdc677a25220bbe3d817 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 27 Jun 2025 22:01:58 +0000 Subject: [PATCH 14/50] debug --- src/app/modules/inventory-list/list/inventory.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 508e3b07..d4d890a9 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -151,7 +151,7 @@ export class InventoryComponent implements OnDestroy, OnInit { */ getDependencies(org_id: number) { this.orgId = org_id - // this._cycleService.getCycles(this.orgId) + this._cycleService.getCycles(this.orgId) return combineLatest([ this._userService.currentUser$, From 4a270eab042316038a1dbf3fae716a84b5541ad5 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 30 Jun 2025 20:30:18 +0000 Subject: [PATCH 15/50] mapping table fxns and styles --- .../ag-grid/autocomplete.component.html | 16 ++ .../ag-grid/autocomplete.component.ts | 60 +++++++ .../ag-grid/editHeader.component.ts | 17 ++ src/@seed/components/ag-grid/index.ts | 1 + src/@seed/components/index.ts | 1 + src/@seed/utils/string-matching.util.ts | 12 ++ src/app/ag-grid-modules.ts | 30 ++++ .../datasets/data-mappings/column-defs.ts | 156 +++++++++++++++++ .../datasets/data-mappings/constants.ts | 57 +++++++ .../data-mappings/data-mapping.component.html | 15 +- .../data-mappings/data-mapping.component.ts | 159 +++++++++--------- .../inventory-list/map/labels.component.ts | 14 +- src/main.ts | 14 +- 13 files changed, 441 insertions(+), 111 deletions(-) create mode 100644 src/@seed/components/ag-grid/autocomplete.component.html create mode 100644 src/@seed/components/ag-grid/autocomplete.component.ts create mode 100644 src/@seed/components/ag-grid/editHeader.component.ts create mode 100644 src/@seed/components/ag-grid/index.ts create mode 100644 src/@seed/utils/string-matching.util.ts create mode 100644 src/app/ag-grid-modules.ts create mode 100644 src/app/modules/datasets/data-mappings/column-defs.ts create mode 100644 src/app/modules/datasets/data-mappings/constants.ts diff --git a/src/@seed/components/ag-grid/autocomplete.component.html b/src/@seed/components/ag-grid/autocomplete.component.html new file mode 100644 index 00000000..db0592f0 --- /dev/null +++ b/src/@seed/components/ag-grid/autocomplete.component.html @@ -0,0 +1,16 @@ + + + + @for (option of filteredOptions; track $index) { + + {{ option }} + + } + + \ No newline at end of file diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts new file mode 100644 index 00000000..1b7e7f11 --- /dev/null +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -0,0 +1,60 @@ +import type { AfterViewInit, ElementRef } from '@angular/core' +import { Component, ViewChild } from '@angular/core' +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatAutocompleteModule } from '@angular/material/autocomplete' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import type { ICellEditorAngularComp } from 'ag-grid-angular' +import type { ICellEditorParams } from 'ag-grid-community' +import { isOrderedSubset } from '@seed/utils/string-matching.util' + +@Component({ + selector: 'seed-ag-grid-auto-complete-cell', + templateUrl: './autocomplete.component.html', + imports: [ + FormsModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + ], +}) +export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterViewInit { + @ViewChild('input') input!: ElementRef + inputCtrl = new FormControl('') + filteredOptions: string[] = [] + + params!: unknown + options: string[] = [] + + agInit(params: ICellEditorParams): void { + this.params = params + this.options = ((params as unknown) as { values: string[] }).values || [] + this.inputCtrl.setValue(params.value as string) + this.filteredOptions = [...this.options] + this.inputCtrl.valueChanges.subscribe((value) => { + // autocomplete + this.filteredOptions = this.options.filter((option) => { + return isOrderedSubset(value, option) + }) + }) + } + + getValue() { + return this.inputCtrl.value + } + + ngAfterViewInit(): void { + setTimeout(() => { + this.input.nativeElement.focus() + }) + } + + onKeyDown(event: KeyboardEvent) { + // if enter or tab, accept the value and stop propagation + const exitKeys = ['Enter', 'Tab'] + if (!exitKeys.includes(event.key)) { + event.stopPropagation() + } + } +} diff --git a/src/@seed/components/ag-grid/editHeader.component.ts b/src/@seed/components/ag-grid/editHeader.component.ts new file mode 100644 index 00000000..7facf366 --- /dev/null +++ b/src/@seed/components/ag-grid/editHeader.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'seed-edit-header', + template: ` +
+ {{ name }} + edit +
+ `, +}) +export class EditHeaderComponent { + name: string + agInit(params: { name: string }): void { + this.name = params.name + } +} diff --git a/src/@seed/components/ag-grid/index.ts b/src/@seed/components/ag-grid/index.ts new file mode 100644 index 00000000..82292220 --- /dev/null +++ b/src/@seed/components/ag-grid/index.ts @@ -0,0 +1 @@ +export * from './editHeader.component' \ No newline at end of file diff --git a/src/@seed/components/index.ts b/src/@seed/components/index.ts index 9f6aab18..6cebd7eb 100644 --- a/src/@seed/components/index.ts +++ b/src/@seed/components/index.ts @@ -4,6 +4,7 @@ export * from './card' export * from './clipboard' export * from './delete-modal' export * from './drawer' +export * from './ag-grid' export * from './label' export * from './loading-bar' export * from './masonry' diff --git a/src/@seed/utils/string-matching.util.ts b/src/@seed/utils/string-matching.util.ts new file mode 100644 index 00000000..46ee1a56 --- /dev/null +++ b/src/@seed/utils/string-matching.util.ts @@ -0,0 +1,12 @@ +/** + * Returns true if all characters in `input` appear in `target` in the same order (not necessarily consecutively). + * Used for fuzzy matching like 'ac' matching 'abc' but not 'cab'. + */ +export const isOrderedSubset = (input: string, target: string): boolean => { + let i = 0 + for (const char of target.toLowerCase()) { + if (char === input[i]?.toLowerCase()) i++ + if (i === input.length) return true + } + return i === input.length +} diff --git a/src/app/ag-grid-modules.ts b/src/app/ag-grid-modules.ts new file mode 100644 index 00000000..da988e0f --- /dev/null +++ b/src/app/ag-grid-modules.ts @@ -0,0 +1,30 @@ +import { + CellStyleModule, + CheckboxEditorModule, + ClientSideRowModelModule, + ColumnAutoSizeModule, + CustomEditorModule, + EventApiModule, + ModuleRegistry, + PaginationModule, + RenderApiModule, + RowApiModule, + SelectEditorModule, + TextEditorModule, + ValidationModule, +} from 'ag-grid-community' + +ModuleRegistry.registerModules([ + CellStyleModule, + CheckboxEditorModule, + ClientSideRowModelModule, + ColumnAutoSizeModule, + CustomEditorModule, + EventApiModule, + PaginationModule, + RenderApiModule, + RowApiModule, + SelectEditorModule, + TextEditorModule, + ValidationModule, +]) diff --git a/src/app/modules/datasets/data-mappings/column-defs.ts b/src/app/modules/datasets/data-mappings/column-defs.ts new file mode 100644 index 00000000..de97fea3 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/column-defs.ts @@ -0,0 +1,156 @@ +import { EditHeaderComponent } from '@seed/components' +import { AutocompleteCellComponent } from '@seed/components/ag-grid/autocomplete.component' +import type { CellValueChangedEvent, ColDef, ColGroupDef, ICellRendererParams } from 'ag-grid-community' +import { dataTypeOptions, unitMap } from './constants' + +export const gridOptions = { + singleClickEdit: true, + suppressMovableColumns: true, + // defaultColDef: { cellClass: (params: CellClassParams) => params.colDef.editable ? 'bg-primary bg-opacity-25' : '' }, +} + +// Special cases +const canEdit = (dataType: string, field: string, isNewColumn: boolean): boolean => { + const editMap: Record = { + dataType: isNewColumn, + inventory_type: true, + units: ['EUI', 'Area', 'GHG', 'GHG Intensity', 'Water use', 'WUI'].includes(dataType), + } + + return editMap[field] +} + +const dropdownRenderer = (params: ICellRendererParams) => { + const value = params.value as string + const data = params.data as { dataType: string; isNewColumn: boolean } + const field = params.colDef.field + + if (!canEdit(data.dataType, field, data.isNewColumn)) { + return value + } + + return ` +
+ ${value ?? ''} + arrow_drop_down +
+ ` +} + +const canEditClass = 'bg-primary bg-opacity-25 rounded' + +export const buildColumnDefs = ( + columnNames: string[], + uploadedFilename: string, + seedHeaderChange: (event: CellValueChangedEvent) => void, + dataTypeChange: (event: CellValueChangedEvent) => void, +): (ColDef | ColGroupDef)[] => { + const seedCols: ColDef[] = [ + { field: 'isExtraData', hide: true }, + { field: 'isNewColumn', hide: true }, + // OMIT + { + field: 'omit', + headerName: 'Omit', + cellEditor: 'agCheckboxCellEditor', + editable: true, + width: 70, + }, + { + field: 'inventory_type', + headerName: 'Inventory Type', + headerComponent: EditHeaderComponent, + headerComponentParams: { + name: 'Inventory Type', + }, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { + values: ['Property', 'Tax Lot'], + }, + cellRenderer: dropdownRenderer, + editable: true, + cellClass: canEditClass, + }, + // SEED HEADER + { + field: 'seed_header', + headerName: 'SEED Header', + cellEditor: AutocompleteCellComponent, + cellEditorParams: { + values: columnNames, + }, + headerComponent: EditHeaderComponent, + headerComponentParams: { + name: 'SEED Header', + }, + onCellValueChanged: seedHeaderChange, + editable: true, + cellClass: canEditClass, + }, + ] + + const fileCols: ColDef[] = [ + // DATA TYPE: Editable if isExtraData is true + { + field: 'dataType', + headerName: 'Data Type', + headerComponent: EditHeaderComponent, + headerComponentParams: { + name: 'Data Type', + }, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { + values: dataTypeOptions, + }, + cellRenderer: dropdownRenderer, + editable: (params) => { + const data = params?.data as { isNewColumn: boolean } + return canEdit(null, 'dataType', data.isNewColumn) + }, + onCellValueChanged: dataTypeChange, + cellClass: (params) => { + const data = params?.data as { isNewColumn: boolean } + return canEdit(null, 'dataType', data.isNewColumn) ? canEditClass : '' + }, + }, + /* UNITS: Only editable for Area, EUI, GHG, GHGI, Water use, WUI + * Dropdowns are populated based on a unit type map + */ + { + field: 'units', + headerName: 'Units', + headerComponent: EditHeaderComponent, + headerComponentParams: { + name: 'Units', + }, + cellEditor: 'agSelectCellEditor', + cellEditorParams: ({ data }: { data: { dataType: string } }) => { + return { + values: unitMap[data.dataType] ?? [], + } + }, + cellRenderer: dropdownRenderer, + editable: (params) => { + const data = params?.data as { dataType: string } + return canEdit(data.dataType, 'units', null) + }, + cellClass: (params) => { + const data = params?.data as { dataType: string } + return canEdit(data.dataType, 'units', null) ? canEditClass : '' + }, + }, + { field: 'file_header', headerName: 'Data File Header' }, + { field: 'row1', headerName: 'Row 1' }, + { field: 'row2', headerName: 'Row 2' }, + { field: 'row3', headerName: 'Row 3' }, + { field: 'row4', headerName: 'Row 4' }, + { field: 'row5', headerName: 'Row 5' }, + ] + + const columnDefs = [ + { headerName: 'SEED', children: seedCols } as ColGroupDef, + { headerName: uploadedFilename, children: fileCols } as ColGroupDef, + ] + + return columnDefs +} diff --git a/src/app/modules/datasets/data-mappings/constants.ts b/src/app/modules/datasets/data-mappings/constants.ts new file mode 100644 index 00000000..51dded44 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/constants.ts @@ -0,0 +1,57 @@ +export const dataTypeMap: Record = { + None: { display: 'None', units: null }, + number: { display: 'Number', units: null }, + integer: { display: 'Integer', units: null }, + string: { display: 'Text', units: null }, + datetime: { display: 'Datetime', units: null }, + date: { display: 'Date', units: null }, + boolean: { display: 'Boolean', units: null }, + area: { display: 'Area', units: 'ft²' }, + eui: { display: 'EUI', units: 'kBtu/ft²/year' }, + geometry: { display: 'Geometry', units: null }, + ghg: { display: 'GHG', units: 'MtC02e/year' }, + ghg_intensity: { display: 'GHG Intensity', units: 'kgCO2e/ft²/year' }, + // water_use: { display: 'Water Use', units: 'kgal/year' }, + // wui: { display: 'Water Use Intensity', units: 'gal/ft²/year' }, +} + +export const unitMap: Record = { + Area: ['ft²', 'm²'], + EUI: [ + 'kBtu/ft²/year', + 'kWh/m²/year', + 'GJ/m²/year', + 'MJ/m²/year', + 'kBtu/m²/year', + ], + GHG: ['MtCO2e/year', 'kgCO2e/year'], + 'GHG Intensity': [ + 'MtCO2e/ft²/year', + 'kgCO2e/ft²/year', + 'MtCO2e/m²/year', + 'kgCO2e/m²/year', + ], + 'Water Use': ['kgal/year', 'gal/year', 'L/year'], + 'Water Use Intensity': [ + 'kgal/ft²/year', + 'gal/ft²/year', + 'L/m²/year', + ], +} + +export const dataTypeOptions = [ + 'None', + 'Number', + 'Integer', + 'Text', + 'Datetime', + 'Date', + 'Boolean', + 'Area', + 'EUI', + 'Geometry', + 'GHG', + 'GHG Intensity', + 'Water Use', + 'Water Use Intensity', +] diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 1c7a7c64..6d41d6a8 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -41,16 +41,17 @@
-
- Set all fields to - - Property - Tax Lot - -
+ + Property Types + Tax Lot Types +
+
+
+
Editable Cell
+
() private _configService = inject(ConfigService) private _cycleService = inject(CycleService) + private _columnService = inject(ColumnService) private _datasetService = inject(DatasetService) private _inventoryService = inject(InventoryService) private _mappingService = inject(MappingService) private _router = inject(ActivatedRoute) private _userService = inject(UserService) columns: Column[] + columnNames: string[] + columnMap: Record columnDefs: ColDef[] currentProfile: Profile cycle: Cycle - defaultInventoryType = 'Property' + defaultInventoryType: InventoryDisplayType = 'Property' + defaultRow: Record fileId = this._router.snapshot.params.id as number firstFiveRows: Record[] helpOpened = false importFile: ImportFile gridApi: GridApi - gridOptions = { - singleClickEdit: true, - suppressMovableColumns: true, - } + gridOptions = gridOptions gridTheme$ = this._configService.gridTheme$ mappingSuggestions: MappingSuggestionsResponse orgId: number @@ -70,7 +77,6 @@ export class DataMappingComponent implements OnDestroy, OnInit { rowData: Record[] = [] ngOnInit(): void { - console.log('Data Mapping Component Initialized') this._userService.currentOrganizationId$ .pipe( take(1), @@ -107,74 +113,31 @@ export class DataMappingComponent implements OnDestroy, OnInit { this.firstFiveRows = firstFiveRows this.mappingSuggestions = mappingSuggestions this.rawColumnNames = rawColumnNames + this.setColumns() }), ) } setGrid() { + this.defaultRow = { + isExtraData: false, + omit: null, + seed_header: null, + inventory_type: this.defaultInventoryType, + dataType: null, + units: null, + } this.setColumnDefs() this.setRowData() } setColumnDefs() { - const seedCols: ColDef[] = [ - { - field: 'omit', - headerName: 'Omit', - editable: true, - cellEditor: 'agCheckboxCellEditor', - }, - { - field: 'inventory_type', - headerName: 'Inventory Type', - editable: true, - cellEditor: 'agSelectCellEditor', - cellEditorParams: { - values: ['Property', 'Tax Lot'], - }, - cellRenderer: this.dropdownRenderer, - }, - { - field: 'seed_header', - headerName: 'SEED Header', - editable: true, - cellRenderer: this.inputRenderer, - cellEditor: 'agTextCellEditor', - }, - ] - - const fileCols: ColDef[] = [ - { field: 'data_type', headerName: 'Data Type' }, - { field: 'units', headerName: 'Units' }, - { field: 'file_header', headerName: 'Data File Header' }, - { field: 'row1', headerName: 'Row 1' }, - { field: 'row2', headerName: 'Row 2' }, - { field: 'row3', headerName: 'Row 3' }, - { field: 'row4', headerName: 'Row 4' }, - { field: 'row5', headerName: 'Row 5' }, - ] - - this.columnDefs = [ - { headerName: 'SEED', children: seedCols } as ColGroupDef, - { headerName: this.importFile.uploaded_filename, children: fileCols } as ColGroupDef, - ] - } - - dropdownRenderer({ value }: { value: string }) { - return ` -
- ${value ?? ''} - arrow_drop_down -
- ` - } - - inputRenderer({ value }: { value: string }) { - return ` -
- ${value ?? ''} -
- ` + this.columnDefs = buildColumnDefs( + this.columnNames, + this.importFile.uploaded_filename, + this.seedHeaderChange.bind(this), + this.dataTypeChange.bind(this), + ) } setRowData() { @@ -182,11 +145,11 @@ export class DataMappingComponent implements OnDestroy, OnInit { // transpose first 5 rows to fit into the grid for (const header of this.rawColumnNames) { - const keys = ['file_header', 'row1', 'row2', 'row3', 'row4', 'row5'] + const keys = ['row1', 'row2', 'row3', 'row4', 'row5'] const values = this.firstFiveRows.map((r) => r[header]) - values.unshift(header) + const rows: Record = Object.fromEntries(keys.map((k, i) => [k, values[i]])) - const data = Object.fromEntries(keys.map((k, i) => [k, values[i]])) + const data = { ...rows, ...this.defaultRow, file_header: header } this.rowData.push(data) } @@ -203,6 +166,44 @@ export class DataMappingComponent implements OnDestroy, OnInit { setAllInventoryType(value: InventoryDisplayType) { this.defaultInventoryType = value this.gridApi.forEachNode((n) => n.setDataValue('inventory_type', value)) + this.setColumns() + } + + setColumns() { + this.columns = this.defaultInventoryType === 'Tax Lot' ? this.mappingSuggestions?.taxlot_columns : this.mappingSuggestions?.property_columns + this.columnNames = this.columns.map((c) => c.display_name) + this.columnMap = this.columns.reduce((acc, curr) => ({ ...acc, [curr.display_name]: curr }), {}) + } + + seedHeaderChange = (params: CellValueChangedEvent): void => { + const node = params.node as RowNode + const newValue = params.newValue as string + const column = this.columnMap[newValue] ?? null + + const dataTypeConfig = dataTypeMap[column?.data_type] ?? { display: 'None', units: null } + + node.setData({ + ...node.data, + isNewColumn: !column, + isExtraData: column?.is_extra_data ?? true, + dataType: dataTypeConfig.display, + units: dataTypeConfig.units, + }) + + this.refreshNode(node) + } + + dataTypeChange = (params: CellValueChangedEvent): void => { + const node = params.node as RowNode + node.setDataValue('units', null) + this.refreshNode(node) + } + + refreshNode(node: RowNode) { + this.gridApi.refreshCells({ + rowNodes: [node], + force: true, + }) } copyHeadersToSeed() { @@ -210,11 +211,11 @@ export class DataMappingComponent implements OnDestroy, OnInit { const columns = this.defaultInventoryType === 'Tax Lot' ? taxlot_columns : property_columns const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) - this.gridApi.forEachNode((n: RowNode<{ file_header: string }>) => { - const fileHeader = n.data.file_header + this.gridApi.forEachNode((node: RowNode<{ file_header: string }>) => { + const fileHeader = node.data.file_header const suggestedColumnName = suggested_column_mappings[fileHeader][1] const displayName = columnMap[suggestedColumnName] - n.setDataValue('seed_header', displayName) + node.setDataValue('seed_header', displayName) }) } diff --git a/src/app/modules/inventory-list/map/labels.component.ts b/src/app/modules/inventory-list/map/labels.component.ts index c90ee5bc..6c0219c9 100644 --- a/src/app/modules/inventory-list/map/labels.component.ts +++ b/src/app/modules/inventory-list/map/labels.component.ts @@ -11,6 +11,7 @@ import { MatSelectModule } from '@angular/material/select' import type { Label, LabelOperator } from '@seed/api/label' import { OrganizationService } from '@seed/api/organization' import type { CurrentUser } from '@seed/api/user' +import { isOrderedSubset } from '@seed/utils/string-matching.util' @Component({ selector: 'seed-inventory-list-map-labels', @@ -84,7 +85,7 @@ export class LabelsComponent implements OnChanges { labelInputChange(event: Event) { const value = (event.target as HTMLInputElement).value - this.filteredLabels = this.labels.filter((label) => this.isOrderedSubset(value.toLowerCase(), label.name.toLowerCase())) + this.filteredLabels = this.labels.filter((label) => isOrderedSubset(value, label.name)) } onLabelChange() { @@ -99,15 +100,4 @@ export class LabelsComponent implements OnChanges { .updateOrganizationUser(this.currentUser.org_user_id, this.currentUser.org_id, this.currentUser.settings) .subscribe() } - - // determine if a string is a subset of another string, preserving order - // e.g. 'ac' is a subset of 'abc' - isOrderedSubset(input: string, target: string): boolean { - let i = 0 - for (const char of target) { - if (char === input[i]) i++ - if (i === input.length) return true - } - return i === input.length - } } diff --git a/src/main.ts b/src/main.ts index df8740f0..2166129e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,7 @@ import { bootstrapApplication } from '@angular/platform-browser' -import { CheckboxEditorModule, ClientSideRowModelModule, ColumnAutoSizeModule, EventApiModule, ModuleRegistry, PaginationModule, RowApiModule, SelectEditorModule, TextEditorModule, ValidationModule } from 'ag-grid-community' import { AppComponent } from 'app/app.component' import { appConfig } from 'app/app.config' - -ModuleRegistry.registerModules([ - CheckboxEditorModule, - ClientSideRowModelModule, - ColumnAutoSizeModule, - EventApiModule, - PaginationModule, - RowApiModule, - SelectEditorModule, - TextEditorModule, - ValidationModule, -]) +import 'app/ag-grid-modules' bootstrapApplication(AppComponent, appConfig).catch((err: unknown) => { console.error(err) From c9fc08136d1a483b2e7e5d967ac334112a592a11 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 1 Jul 2025 14:55:01 +0000 Subject: [PATCH 16/50] validate data mapping --- src/@seed/api/dataset/dataset.types.ts | 12 +++ .../ag-grid/autocomplete.component.ts | 4 +- .../datasets/data-mappings/column-defs.ts | 39 +++---- .../datasets/data-mappings/constants.ts | 9 +- .../data-mappings/data-mapping.component.html | 12 ++- .../data-mappings/data-mapping.component.ts | 100 +++++++++++++++--- 6 files changed, 134 insertions(+), 42 deletions(-) diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 17003a6d..cf1cf6ba 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -52,3 +52,15 @@ export type ImportFileResponse = { status: 'success'; import_file: ImportFile; } + +export type DataMappingRow = { + from_field: string; + from_units: string | null; + to_data_type: string | null; + to_field: string | null; + to_field_display_name: string | null; + to_table_name: string | null; + omit?: boolean; // optional, used for omitting columns + isExtraData?: boolean; // used internally, not part of the API + isNewColumn?: boolean; // used internally, not part of the API +} diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts index 1b7e7f11..1cfa9db2 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.ts +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -51,8 +51,8 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV } onKeyDown(event: KeyboardEvent) { - // if enter or tab, accept the value and stop propagation - const exitKeys = ['Enter', 'Tab'] + // if enter, accept the value and stop propagation + const exitKeys = ['Enter'] if (!exitKeys.includes(event.key)) { event.stopPropagation() } diff --git a/src/app/modules/datasets/data-mappings/column-defs.ts b/src/app/modules/datasets/data-mappings/column-defs.ts index de97fea3..86113131 100644 --- a/src/app/modules/datasets/data-mappings/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/column-defs.ts @@ -10,11 +10,11 @@ export const gridOptions = { } // Special cases -const canEdit = (dataType: string, field: string, isNewColumn: boolean): boolean => { +const canEdit = (to_data_type: string, field: string, isNewColumn: boolean): boolean => { const editMap: Record = { - dataType: isNewColumn, - inventory_type: true, - units: ['EUI', 'Area', 'GHG', 'GHG Intensity', 'Water use', 'WUI'].includes(dataType), + to_data_type: isNewColumn, + to_table_name: true, + from_units: ['EUI', 'Area', 'GHG', 'GHG Intensity', 'Water use', 'WUI'].includes(to_data_type), } return editMap[field] @@ -22,10 +22,10 @@ const canEdit = (dataType: string, field: string, isNewColumn: boolean): boolean const dropdownRenderer = (params: ICellRendererParams) => { const value = params.value as string - const data = params.data as { dataType: string; isNewColumn: boolean } + const data = params.data as { to_data_type: string; isNewColumn: boolean } const field = params.colDef.field - if (!canEdit(data.dataType, field, data.isNewColumn)) { + if (!canEdit(data.to_data_type, field, data.isNewColumn)) { return value } @@ -48,6 +48,7 @@ export const buildColumnDefs = ( const seedCols: ColDef[] = [ { field: 'isExtraData', hide: true }, { field: 'isNewColumn', hide: true }, + { field: 'to_field', hide: true }, // OMIT { field: 'omit', @@ -57,7 +58,7 @@ export const buildColumnDefs = ( width: 70, }, { - field: 'inventory_type', + field: 'to_table_name', headerName: 'Inventory Type', headerComponent: EditHeaderComponent, headerComponentParams: { @@ -73,7 +74,7 @@ export const buildColumnDefs = ( }, // SEED HEADER { - field: 'seed_header', + field: 'to_field_display_name', headerName: 'SEED Header', cellEditor: AutocompleteCellComponent, cellEditorParams: { @@ -92,7 +93,7 @@ export const buildColumnDefs = ( const fileCols: ColDef[] = [ // DATA TYPE: Editable if isExtraData is true { - field: 'dataType', + field: 'to_data_type', headerName: 'Data Type', headerComponent: EditHeaderComponent, headerComponentParams: { @@ -105,41 +106,41 @@ export const buildColumnDefs = ( cellRenderer: dropdownRenderer, editable: (params) => { const data = params?.data as { isNewColumn: boolean } - return canEdit(null, 'dataType', data.isNewColumn) + return canEdit(null, 'to_data_type', data.isNewColumn) }, onCellValueChanged: dataTypeChange, cellClass: (params) => { const data = params?.data as { isNewColumn: boolean } - return canEdit(null, 'dataType', data.isNewColumn) ? canEditClass : '' + return canEdit(null, 'to_data_type', data.isNewColumn) ? canEditClass : '' }, }, /* UNITS: Only editable for Area, EUI, GHG, GHGI, Water use, WUI * Dropdowns are populated based on a unit type map */ { - field: 'units', + field: 'from_units', headerName: 'Units', headerComponent: EditHeaderComponent, headerComponentParams: { name: 'Units', }, cellEditor: 'agSelectCellEditor', - cellEditorParams: ({ data }: { data: { dataType: string } }) => { + cellEditorParams: ({ data }: { data: { to_data_type: string } }) => { return { - values: unitMap[data.dataType] ?? [], + values: unitMap[data.to_data_type] ?? [], } }, cellRenderer: dropdownRenderer, editable: (params) => { - const data = params?.data as { dataType: string } - return canEdit(data.dataType, 'units', null) + const data = params?.data as { to_data_type: string } + return canEdit(data.to_data_type, 'from_units', null) }, cellClass: (params) => { - const data = params?.data as { dataType: string } - return canEdit(data.dataType, 'units', null) ? canEditClass : '' + const data = params?.data as { to_data_type: string } + return canEdit(data.to_data_type, 'from_units', null) ? canEditClass : '' }, }, - { field: 'file_header', headerName: 'Data File Header' }, + { field: 'from_field', headerName: 'Data File Header' }, { field: 'row1', headerName: 'Row 1' }, { field: 'row2', headerName: 'Row 2' }, { field: 'row3', headerName: 'Row 3' }, diff --git a/src/app/modules/datasets/data-mappings/constants.ts b/src/app/modules/datasets/data-mappings/constants.ts index 51dded44..00504032 100644 --- a/src/app/modules/datasets/data-mappings/constants.ts +++ b/src/app/modules/datasets/data-mappings/constants.ts @@ -15,6 +15,11 @@ export const dataTypeMap: Record = { // wui: { display: 'Water Use Intensity', units: 'gal/ft²/year' }, } +const dataTypes = ['None', 'Number', 'Integer', 'Text', 'Datetime', 'Date', 'Boolean', 'Area', 'EUI', 'Geometry', 'GHG', 'GHG Intensity'] // 'Water Use', 'Water Use Intensity' +const displayDataTypes = [null, 'number', 'integer', 'string', 'datetime', 'date', 'boolean', 'area', 'eui', 'geometry', 'ghg', 'ghg_intensity'] // 'water_use', 'wui' + +export const displayToDataTypeMap: Record = Object.fromEntries(displayDataTypes.map((k, i) => [k, dataTypes[i]])) + export const unitMap: Record = { Area: ['ft²', 'm²'], EUI: [ @@ -52,6 +57,6 @@ export const dataTypeOptions = [ 'Geometry', 'GHG', 'GHG Intensity', - 'Water Use', - 'Water Use Intensity', + // 'Water Use', + // 'Water Use Intensity', ] diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 6d41d6a8..14c6c70b 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -48,9 +48,15 @@
-
-
-
Editable Cell
+
+
+
+
Editable Cell
+
+ + + +
fileId = this._router.snapshot.params.id as number @@ -72,6 +76,8 @@ export class DataMappingComponent implements OnDestroy, OnInit { gridOptions = gridOptions gridTheme$ = this._configService.gridTheme$ mappingSuggestions: MappingSuggestionsResponse + matchingPropertyColumns: string[] = [] + matchingTaxLotColumns: string[] = [] orgId: number rawColumnNames: string[] = [] rowData: Record[] = [] @@ -84,6 +90,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { switchMap(() => this.getImportFile()), filter(Boolean), switchMap(() => this.getMappingData()), + switchMap(() => this.getMatchingColumns()), tap(() => { this.setGrid() }), ) .subscribe() @@ -118,14 +125,29 @@ export class DataMappingComponent implements OnDestroy, OnInit { ) } + getMatchingColumns() { + return forkJoin([ + this._organizationService.getMatchingCriteriaColumns(this.orgId, 'properties'), + this._organizationService.getMatchingCriteriaColumns(this.orgId, 'taxlots'), + ]) + .pipe( + take(1), + tap(([matchingPropertyColumns, matchingTaxLotColumns]) => { + this.matchingPropertyColumns = matchingPropertyColumns as string[] + this.matchingTaxLotColumns = matchingTaxLotColumns as string[] + }), + ) + + } + setGrid() { this.defaultRow = { isExtraData: false, omit: null, - seed_header: null, - inventory_type: this.defaultInventoryType, - dataType: null, - units: null, + to_field_display_name: null, + to_table_name: this.defaultInventoryType, + to_data_type: null, + from_units: null, } this.setColumnDefs() this.setRowData() @@ -149,7 +171,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { const values = this.firstFiveRows.map((r) => r[header]) const rows: Record = Object.fromEntries(keys.map((k, i) => [k, values[i]])) - const data = { ...rows, ...this.defaultRow, file_header: header } + const data = { ...rows, ...this.defaultRow, from_field: header } this.rowData.push(data) } @@ -165,7 +187,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { setAllInventoryType(value: InventoryDisplayType) { this.defaultInventoryType = value - this.gridApi.forEachNode((n) => n.setDataValue('inventory_type', value)) + this.gridApi.forEachNode((node) => node.setDataValue('to_table_display_name', value)) this.setColumns() } @@ -181,21 +203,24 @@ export class DataMappingComponent implements OnDestroy, OnInit { const column = this.columnMap[newValue] ?? null const dataTypeConfig = dataTypeMap[column?.data_type] ?? { display: 'None', units: null } - + const to_field = column?.column_name ?? newValue + console.log('set dataType', dataTypeConfig.display) node.setData({ ...node.data, isNewColumn: !column, isExtraData: column?.is_extra_data ?? true, - dataType: dataTypeConfig.display, - units: dataTypeConfig.units, + to_data_type: dataTypeConfig.display, + to_field, + from_units: dataTypeConfig.units, }) this.refreshNode(node) + this.validateData() } dataTypeChange = (params: CellValueChangedEvent): void => { const node = params.node as RowNode - node.setDataValue('units', null) + node.setDataValue('from_units', null) this.refreshNode(node) } @@ -211,14 +236,57 @@ export class DataMappingComponent implements OnDestroy, OnInit { const columns = this.defaultInventoryType === 'Tax Lot' ? taxlot_columns : property_columns const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) - this.gridApi.forEachNode((node: RowNode<{ file_header: string }>) => { - const fileHeader = node.data.file_header + this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { + const fileHeader = node.data.from_field const suggestedColumnName = suggested_column_mappings[fileHeader][1] const displayName = columnMap[suggestedColumnName] - node.setDataValue('seed_header', displayName) + node.setDataValue('to_field_display_name', displayName) }) } + // Format data for backend consumption + mapData() { + const result = [] + this.gridApi.forEachNode(({ data }: { data: DataMappingRow }) => { + if (data.omit) return // skip omitted rows + + const { from_field, from_units, to_data_type, to_field, to_field_display_name, to_table_name } = data + + result.push({ + from_field, + from_units: from_units?.replace('²', '**2') ?? null, + to_data_type: displayToDataTypeMap[to_data_type] ?? null, + to_field, + to_field_display_name, + to_table_name: to_table_name ?? this.defaultInventoryType, + }) + }) + console.log('Mapped Data:', result) + } + + validateData() { + const matchingColumns = this.defaultInventoryType === 'Tax Lot' ? this.matchingTaxLotColumns : this.matchingPropertyColumns + const toFields = [] + this.gridApi.forEachNode((node: RowNode) => { + if (node.data.omit) return // skip omitted rows + toFields.push(node.data.to_field) + }) + + // no duplicates + if (toFields.length !== new Set(toFields).size) { + this.dataValid = false + return + } + // at least one matching column + const hasMatchingCol = toFields.some((col) => matchingColumns.includes(col)) + if (!hasMatchingCol) { + this.dataValid = false + return + } + + this.dataValid = true + } + toggleHelp = () => { this.helpOpened = !this.helpOpened } From 966a8e27673869bf5f17db6661385e67d1ac30f5 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 2 Jul 2025 14:27:38 +0000 Subject: [PATCH 17/50] save mapping step --- src/@seed/api/dataset/dataset.types.ts | 10 + src/@seed/api/mapping/mapping.service.ts | 37 ++- .../data-mappings/data-mapping.component.html | 76 ++--- .../data-mappings/data-mapping.component.ts | 275 +++++++----------- .../data-mappings/{ => step1}/column-defs.ts | 0 .../data-mappings/{ => step1}/constants.ts | 4 +- .../step1/map-data.component.html | 42 +++ .../data-mappings/step1/map-data.component.ts | 245 ++++++++++++++++ .../step3/save-mappings.component.html | 29 ++ .../step3/save-mappings.component.ts | 97 ++++++ .../step4/match-marge.compponent.html | 3 + .../step4/match-margecompponent.ts | 30 ++ 12 files changed, 641 insertions(+), 207 deletions(-) rename src/app/modules/datasets/data-mappings/{ => step1}/column-defs.ts (100%) rename src/app/modules/datasets/data-mappings/{ => step1}/constants.ts (82%) create mode 100644 src/app/modules/datasets/data-mappings/step1/map-data.component.html create mode 100644 src/app/modules/datasets/data-mappings/step1/map-data.component.ts create mode 100644 src/app/modules/datasets/data-mappings/step3/save-mappings.component.html create mode 100644 src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts create mode 100644 src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html create mode 100644 src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index cf1cf6ba..1134c480 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -64,3 +64,13 @@ export type DataMappingRow = { isExtraData?: boolean; // used internally, not part of the API isNewColumn?: boolean; // used internally, not part of the API } + +export type MappedData = { + mappings: DataMappingRow[]; +} + +export type MappingResultsResponse = { + status: string; + properties: Record[]; + tax_lots: Record[]; +} diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index ccfb7ac3..78de21fc 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -2,8 +2,10 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import { ErrorService } from '@seed/services' import { UserService } from '../user' -import { catchError, map, type Observable } from 'rxjs' +import { catchError, map, tap, type Observable } from 'rxjs' import type { FirstFiveRowsResponse, MappingSuggestionsResponse, RawColumnNamesResponse } from './mapping.types' +import { MappedData, MappingResultsResponse } from '../dataset' +import { ProgressResponse } from '../progress' @Injectable({ providedIn: 'root' }) export class MappingService { @@ -42,4 +44,37 @@ export class MappingService { }), ) } + + startMapping(orgId: number, importFileId: number, mappedData: MappedData): Observable { + const url = `api/v3/organizations/${orgId}/column_mappings/?import_file_id=${importFileId}` + return this._httpClient.post(url, mappedData) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting mapping') + }), + ) + } + + remapBuildings(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/map/?organization_id=${orgId}` + return this._httpClient.post(url, { remap: true, mark_as_done: false }) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error remapping buildings') + }), + ) + } + + mappingResults(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/mapping_results/?organization_id=${orgId}` + return this._httpClient.post(url, {}) + .pipe( + tap((response) => { + console.log(response) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching mapping results') + }), + ) + } } diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 14c6c70b..e24d0927 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -7,7 +7,7 @@ actionIcon: 'fa-solid:circle-question', }" > -
+
@@ -28,45 +28,47 @@ -
-
Cycle
-
{{ cycle?.name }}
-
-
-
Column Profile
-
none selected
-
- - -
- - - - Property Types - Tax Lot Types - -
- - -
-
-
-
Editable Cell
-
+ + + + + + + + - + + + + + + -
+ - - +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index b63cff21..1cc33d99 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' -import { Component, inject } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { MatButtonToggleModule } from '@angular/material/button-toggle' @@ -9,89 +9,109 @@ import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatSelectModule } from '@angular/material/select' import { MatSidenavModule } from '@angular/material/sidenav' +import type { MatStepper} from '@angular/material/stepper' +import { MatStepperModule } from '@angular/material/stepper' import { ActivatedRoute } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' -import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' import { catchError, filter, forkJoin, of, Subject, switchMap, take, tap } from 'rxjs' -import { type Column, ColumnService } from '@seed/api/column' +import { ColumnService, type Column } from '@seed/api/column' import type { Cycle } from '@seed/api/cycle' import { CycleService } from '@seed/api/cycle/cycle.service' -import type { DataMappingRow, ImportFile } from '@seed/api/dataset' +import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import { DatasetService } from '@seed/api/dataset' -import { InventoryService } from '@seed/api/inventory' import type { MappingSuggestionsResponse } from '@seed/api/mapping' import { MappingService } from '@seed/api/mapping' -import { OrganizationService } from '@seed/api/organization' +import { Organization, OrganizationService } from '@seed/api/organization' +import type { ProgressResponse } from '@seed/api/progress' import { UserService } from '@seed/api/user' -import { PageComponent } from '@seed/components' -import { ConfigService } from '@seed/services' -import type { InventoryDisplayType, Profile } from 'app/modules/inventory' -import { buildColumnDefs, gridOptions } from './column-defs' -import { dataTypeMap, displayToDataTypeMap } from './constants' +import { PageComponent, ProgressBarComponent } from '@seed/components' +import type { ProgressBarObj } from '@seed/services/uploader' +import { UploaderService } from '@seed/services/uploader' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { Profile } from 'app/modules/inventory' import { HelpComponent } from './help.component' +import { MapDataComponent } from './step1/map-data.component' +import { SaveMappingsComponent } from './step3/save-mappings.component' +import { MatchMergeProgressComponent } from './step4/match-margecompponent' @Component({ - selector: 'seed-data-mapping', + selector: 'seed-data-mapping-stepper', templateUrl: './data-mapping.component.html', imports: [ AgGridAngular, CommonModule, + FormsModule, HelpComponent, + MapDataComponent, + MatchMergeProgressComponent, MatButtonModule, MatButtonToggleModule, MatDividerModule, MatIconModule, MatSidenavModule, MatSelectModule, + MatStepperModule, PageComponent, + ProgressBarComponent, ReactiveFormsModule, - FormsModule, + SaveMappingsComponent, ], }) export class DataMappingComponent implements OnDestroy, OnInit { + @ViewChild('stepper') stepper!: MatStepper + @ViewChild(MapDataComponent) mapDataComponent!: MapDataComponent private readonly _unsubscribeAll$ = new Subject() - private _configService = inject(ConfigService) - private _cycleService = inject(CycleService) private _columnService = inject(ColumnService) + private _cycleService = inject(CycleService) private _datasetService = inject(DatasetService) - private _inventoryService = inject(InventoryService) private _mappingService = inject(MappingService) private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) private _router = inject(ActivatedRoute) + private _uploaderService = inject(UploaderService) private _userService = inject(UserService) columns: Column[] columnNames: string[] - columnMap: Record - columnDefs: ColDef[] + completed = { 1: false, 2: false, 3: false, 4: false } currentProfile: Profile cycle: Cycle - dataValid = false - defaultInventoryType: InventoryDisplayType = 'Property' - defaultRow: Record fileId = this._router.snapshot.params.id as number firstFiveRows: Record[] helpOpened = false importFile: ImportFile - gridApi: GridApi - gridOptions = gridOptions - gridTheme$ = this._configService.gridTheme$ + mappingResultsResponse: MappingResultsResponse mappingSuggestions: MappingSuggestionsResponse matchingPropertyColumns: string[] = [] matchingTaxLotColumns: string[] = [] + org: Organization orgId: number + propertyColumns: Column[] rawColumnNames: string[] = [] - rowData: Record[] = [] + taxlotColumns: Column[] + + progressBarObj: ProgressBarObj = { + message: [], + progress: 0, + total: 100, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } ngOnInit(): void { - this._userService.currentOrganizationId$ + // this._userService.currentOrganizationId$ + this._organizationService.currentOrganization$ .pipe( take(1), - tap((orgId) => this.orgId = orgId), + tap((org) => { + this.orgId = org.id + this.org = org + }), switchMap(() => this.getImportFile()), filter(Boolean), switchMap(() => this.getMappingData()), - switchMap(() => this.getMatchingColumns()), - tap(() => { this.setGrid() }), + switchMap(() => this.getColumns()), ) .subscribe() } @@ -106,6 +126,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { }), ) } + getMappingData() { return forkJoin([ this._cycleService.getCycle(this.orgId, this.importFile.cycle), @@ -120,171 +141,91 @@ export class DataMappingComponent implements OnDestroy, OnInit { this.firstFiveRows = firstFiveRows this.mappingSuggestions = mappingSuggestions this.rawColumnNames = rawColumnNames - this.setColumns() + // this.setColumns() }), ) } - getMatchingColumns() { + getColumns() { return forkJoin([ this._organizationService.getMatchingCriteriaColumns(this.orgId, 'properties'), this._organizationService.getMatchingCriteriaColumns(this.orgId, 'taxlots'), + this._columnService.propertyColumns$.pipe(take(1)), + this._columnService.taxLotColumns$.pipe(take(1)), ]) .pipe( take(1), - tap(([matchingPropertyColumns, matchingTaxLotColumns]) => { + tap(([ + matchingPropertyColumns, + matchingTaxLotColumns, + propertyColumns, + taxlotColumns, + ]) => { this.matchingPropertyColumns = matchingPropertyColumns as string[] this.matchingTaxLotColumns = matchingTaxLotColumns as string[] + this.propertyColumns = propertyColumns + this.taxlotColumns = taxlotColumns }), ) - - } - - setGrid() { - this.defaultRow = { - isExtraData: false, - omit: null, - to_field_display_name: null, - to_table_name: this.defaultInventoryType, - to_data_type: null, - from_units: null, - } - this.setColumnDefs() - this.setRowData() } - setColumnDefs() { - this.columnDefs = buildColumnDefs( - this.columnNames, - this.importFile.uploaded_filename, - this.seedHeaderChange.bind(this), - this.dataTypeChange.bind(this), - ) + onCompleted(step: number) { + this.completed[step] = true + this.stepper.next() } - setRowData() { - this.rowData = [] - - // transpose first 5 rows to fit into the grid - for (const header of this.rawColumnNames) { - const keys = ['row1', 'row2', 'row3', 'row4', 'row5'] - const values = this.firstFiveRows.map((r) => r[header]) - const rows: Record = Object.fromEntries(keys.map((k, i) => [k, values[i]])) + startMapping() { + const mappedData = this.mapDataComponent.mappedData + this.columns = this.mapDataComponent.defaultInventoryType === 'Tax Lot' ? this.taxlotColumns : this.propertyColumns + this.nextStep(1) - const data = { ...rows, ...this.defaultRow, from_field: header } - this.rowData.push(data) + const failureFn = () => { + this._snackBar.alert('Error starting mapping') } - - for (const row of this.rowData) { - row.omit = false + const successFn = () => { + this.nextStep(2) + this.getMappingResults() } - } - - onGridReady(agGrid: GridReadyEvent) { - this.gridApi = agGrid.api - // this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) - } - - setAllInventoryType(value: InventoryDisplayType) { - this.defaultInventoryType = value - this.gridApi.forEachNode((node) => node.setDataValue('to_table_display_name', value)) - this.setColumns() - } - - setColumns() { - this.columns = this.defaultInventoryType === 'Tax Lot' ? this.mappingSuggestions?.taxlot_columns : this.mappingSuggestions?.property_columns - this.columnNames = this.columns.map((c) => c.display_name) - this.columnMap = this.columns.reduce((acc, curr) => ({ ...acc, [curr.display_name]: curr }), {}) - } - - seedHeaderChange = (params: CellValueChangedEvent): void => { - const node = params.node as RowNode - const newValue = params.newValue as string - const column = this.columnMap[newValue] ?? null - - const dataTypeConfig = dataTypeMap[column?.data_type] ?? { display: 'None', units: null } - const to_field = column?.column_name ?? newValue - console.log('set dataType', dataTypeConfig.display) - node.setData({ - ...node.data, - isNewColumn: !column, - isExtraData: column?.is_extra_data ?? true, - to_data_type: dataTypeConfig.display, - to_field, - from_units: dataTypeConfig.units, - }) - - this.refreshNode(node) - this.validateData() - } - dataTypeChange = (params: CellValueChangedEvent): void => { - const node = params.node as RowNode - node.setDataValue('from_units', null) - this.refreshNode(node) - } - - refreshNode(node: RowNode) { - this.gridApi.refreshCells({ - rowNodes: [node], - force: true, - }) - } - - copyHeadersToSeed() { - const { property_columns, taxlot_columns, suggested_column_mappings } = this.mappingSuggestions - const columns = this.defaultInventoryType === 'Tax Lot' ? taxlot_columns : property_columns - const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) - - this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { - const fileHeader = node.data.from_field - const suggestedColumnName = suggested_column_mappings[fileHeader][1] - const displayName = columnMap[suggestedColumnName] - node.setDataValue('to_field_display_name', displayName) - }) + this._mappingService.startMapping(this.orgId, this.fileId, mappedData) + .pipe( + take(1), + switchMap(() => this._mappingService.remapBuildings(this.orgId, this.fileId)), + tap((response: ProgressResponse) => { + this.progressBarObj.progress = response.progress + }), + switchMap((data) => { + return this._uploaderService.checkProgressLoop({ + progressKey: data.progress_key, + offset: 0, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + }), + catchError((error) => { + console.log('Error starting mapping:', error) + return of(null) + }), + ) + .subscribe() } - // Format data for backend consumption - mapData() { - const result = [] - this.gridApi.forEachNode(({ data }: { data: DataMappingRow }) => { - if (data.omit) return // skip omitted rows - - const { from_field, from_units, to_data_type, to_field, to_field_display_name, to_table_name } = data - - result.push({ - from_field, - from_units: from_units?.replace('²', '**2') ?? null, - to_data_type: displayToDataTypeMap[to_data_type] ?? null, - to_field, - to_field_display_name, - to_table_name: to_table_name ?? this.defaultInventoryType, - }) - }) - console.log('Mapped Data:', result) + getMappingResults(): void { + this.nextStep(2) + this._mappingService.mappingResults(this.orgId, this.fileId) + .pipe( + tap((mappingResultsResponse) => { this.mappingResultsResponse = mappingResultsResponse }), + ) + .subscribe() } - validateData() { - const matchingColumns = this.defaultInventoryType === 'Tax Lot' ? this.matchingTaxLotColumns : this.matchingPropertyColumns - const toFields = [] - this.gridApi.forEachNode((node: RowNode) => { - if (node.data.omit) return // skip omitted rows - toFields.push(node.data.to_field) + nextStep(step: number) { + this.completed[step] = true + setTimeout(() => { + this.stepper.next() }) - - // no duplicates - if (toFields.length !== new Set(toFields).size) { - this.dataValid = false - return - } - // at least one matching column - const hasMatchingCol = toFields.some((col) => matchingColumns.includes(col)) - if (!hasMatchingCol) { - this.dataValid = false - return - } - - this.dataValid = true } toggleHelp = () => { diff --git a/src/app/modules/datasets/data-mappings/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts similarity index 100% rename from src/app/modules/datasets/data-mappings/column-defs.ts rename to src/app/modules/datasets/data-mappings/step1/column-defs.ts diff --git a/src/app/modules/datasets/data-mappings/constants.ts b/src/app/modules/datasets/data-mappings/step1/constants.ts similarity index 82% rename from src/app/modules/datasets/data-mappings/constants.ts rename to src/app/modules/datasets/data-mappings/step1/constants.ts index 00504032..b047d01c 100644 --- a/src/app/modules/datasets/data-mappings/constants.ts +++ b/src/app/modules/datasets/data-mappings/step1/constants.ts @@ -15,8 +15,8 @@ export const dataTypeMap: Record = { // wui: { display: 'Water Use Intensity', units: 'gal/ft²/year' }, } -const dataTypes = ['None', 'Number', 'Integer', 'Text', 'Datetime', 'Date', 'Boolean', 'Area', 'EUI', 'Geometry', 'GHG', 'GHG Intensity'] // 'Water Use', 'Water Use Intensity' -const displayDataTypes = [null, 'number', 'integer', 'string', 'datetime', 'date', 'boolean', 'area', 'eui', 'geometry', 'ghg', 'ghg_intensity'] // 'water_use', 'wui' +const displayDataTypes = ['None', 'Number', 'Integer', 'Text', 'Datetime', 'Date', 'Boolean', 'Area', 'EUI', 'Geometry', 'GHG', 'GHG Intensity'] // 'Water Use', 'Water Use Intensity' +const dataTypes = ['None', 'number', 'integer', 'string', 'datetime', 'date', 'boolean', 'area', 'eui', 'geometry', 'ghg', 'ghg_intensity'] // 'water_use', 'wui' export const displayToDataTypeMap: Record = Object.fromEntries(displayDataTypes.map((k, i) => [k, dataTypes[i]])) 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 new file mode 100644 index 00000000..7e3ae3ec --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.html @@ -0,0 +1,42 @@ +
+
Cycle
+
{{ cycle?.name }}
+
+
+
Column Profile
+
none selected
+
+ + +
+ + + + Property Types + Tax Lot Types + +
+ + +
+
+
+
Editable Cell
+
+ + + + +
+ + + \ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts new file mode 100644 index 00000000..cfbce714 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -0,0 +1,245 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { CommonModule } from '@angular/common' +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' +import { Component, EventEmitter, inject, Input, Output } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { MatSidenavModule } from '@angular/material/sidenav' +import { MatStepperModule } from '@angular/material/stepper' +import { ActivatedRoute } from '@angular/router' +import { type Column } from '@seed/api/column' +import type { Cycle } from '@seed/api/cycle' +import type { DataMappingRow, ImportFile } from '@seed/api/dataset' +import type { MappingSuggestionsResponse } from '@seed/api/mapping' +import { PageComponent } from '@seed/components' +import { ConfigService } from '@seed/services' +import type { ProgressBarObj } from '@seed/services/uploader' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' +import type { InventoryDisplayType, Profile } from 'app/modules/inventory' +import { Subject } from 'rxjs' +import { HelpComponent } from '../help.component' +import { buildColumnDefs, gridOptions } from './column-defs' +import { dataTypeMap, displayToDataTypeMap } from './constants' + +@Component({ + selector: 'seed-map-data', + templateUrl: './map-data.component.html', + imports: [ + AgGridAngular, + CommonModule, + HelpComponent, + MatButtonModule, + MatButtonToggleModule, + MatDividerModule, + MatIconModule, + MatSidenavModule, + MatSelectModule, + MatStepperModule, + PageComponent, + ReactiveFormsModule, + FormsModule, + ], +}) +export class MapDataComponent implements OnChanges, OnDestroy { + @Input() orgId: number + @Input() importFile: ImportFile + @Input() cycle: Cycle + @Input() firstFiveRows: Record[] + @Input() mappingSuggestions: MappingSuggestionsResponse + @Input() rawColumnNames: string[] + @Input() matchingPropertyColumns: string[] + @Input() matchingTaxLotColumns: string[] + @Output() completed = new EventEmitter() + + private readonly _unsubscribeAll$ = new Subject() + private _configService = inject(ConfigService) + private _router = inject(ActivatedRoute) + columns: Column[] + columnNames: string[] + columnMap: Record + columnDefs: ColDef[] + currentProfile: Profile + dataValid = false + defaultInventoryType: InventoryDisplayType = 'Property' + defaultRow: Record + fileId = this._router.snapshot.params.id as number + gridApi: GridApi + gridOptions = gridOptions + gridTheme$ = this._configService.gridTheme$ + mappedData: { mappings: DataMappingRow[] } = { mappings: [] } + rowData: Record[] = [] + + progressBarObj: ProgressBarObj = { + message: [], + progress: 0, + total: 100, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + + ngOnChanges(changes: SimpleChanges): void { + // property columns is the last value to be set + if ((changes.matchingPropertyColumns?.currentValue as string[])?.length) { + this.setColumns() + this.setGrid() + } + } + + setGrid() { + this.defaultRow = { + isExtraData: false, + omit: null, + to_field_display_name: null, + to_table_name: this.defaultInventoryType, + to_data_type: null, + from_units: null, + } + this.setColumnDefs() + this.setRowData() + } + + setColumnDefs() { + this.columnDefs = buildColumnDefs( + this.columnNames, + this.importFile.uploaded_filename, + this.seedHeaderChange.bind(this), + this.dataTypeChange.bind(this), + ) + } + + setRowData() { + this.rowData = [] + + // transpose first 5 rows to fit into the grid + for (const header of this.rawColumnNames) { + const keys = ['row1', 'row2', 'row3', 'row4', 'row5'] + const values = this.firstFiveRows.map((r) => r[header]) + const rows: Record = Object.fromEntries(keys.map((k, i) => [k, values[i]])) + + const data = { ...rows, ...this.defaultRow, from_field: header } + this.rowData.push(data) + } + + for (const row of this.rowData) { + row.omit = false + } + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + } + + setAllInventoryType(value: InventoryDisplayType) { + this.defaultInventoryType = value + this.gridApi.forEachNode((node) => node.setDataValue('to_table_display_name', value)) + this.setColumns() + } + + setColumns() { + this.columns = this.defaultInventoryType === 'Tax Lot' ? this.mappingSuggestions?.taxlot_columns : this.mappingSuggestions?.property_columns + this.columnNames = this.columns.map((c) => c.display_name) + this.columnMap = Object.fromEntries(this.columns.map((c) => [c.display_name, c])) + } + + seedHeaderChange = (params: CellValueChangedEvent): void => { + const node = params.node as RowNode + const newValue = params.newValue as string + const column = this.columnMap[newValue] ?? null + + const dataTypeConfig = dataTypeMap[column?.data_type] ?? { display: 'None', units: null } + const to_field = column?.column_name ?? newValue + node.setData({ + ...node.data, + isNewColumn: !column, + isExtraData: column?.is_extra_data ?? true, + to_data_type: dataTypeConfig.display, + to_field, + from_units: dataTypeConfig.units, + }) + + this.refreshNode(node) + this.validateData() + } + + dataTypeChange = (params: CellValueChangedEvent): void => { + const node = params.node as RowNode + node.setDataValue('from_units', null) + this.refreshNode(node) + } + + refreshNode(node: RowNode) { + this.gridApi.refreshCells({ + rowNodes: [node], + force: true, + }) + } + + copyHeadersToSeed() { + const { property_columns, taxlot_columns, suggested_column_mappings } = this.mappingSuggestions + const columns = this.defaultInventoryType === 'Tax Lot' ? taxlot_columns : property_columns + const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) + + this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { + const fileHeader = node.data.from_field + const suggestedColumnName = suggested_column_mappings[fileHeader][1] + const displayName = columnMap[suggestedColumnName] + node.setDataValue('to_field_display_name', displayName) + }) + } + + // Format data for backend consumption + mapData() { + if (!this.dataValid) return + + this.gridApi.forEachNode(({ data }: { data: DataMappingRow }) => { + if (data.omit) return // skip omitted rows + + const { from_field, from_units, to_data_type, to_field, to_field_display_name, to_table_name } = data + + this.mappedData.mappings.push({ + from_field, + from_units: from_units?.replace('²', '**2') ?? null, + to_data_type: displayToDataTypeMap[to_data_type] ?? null, + to_field, + to_field_display_name, + to_table_name: to_table_name === 'Tax Lot' ? 'TaxLotState' : 'PropertyState', + }) + }) + this.completed.emit() + } + + validateData() { + const matchingColumns = this.defaultInventoryType === 'Tax Lot' ? this.matchingTaxLotColumns : this.matchingPropertyColumns + const toFields = [] + this.gridApi.forEachNode((node: RowNode) => { + if (node.data.omit) return // skip omitted rows + toFields.push(node.data.to_field) + }) + + // no duplicates + if (toFields.length !== new Set(toFields).size) { + this.dataValid = false + return + } + // at least one matching column + const hasMatchingCol = toFields.some((col) => matchingColumns.includes(col)) + if (!hasMatchingCol) { + this.dataValid = false + return + } + + this.dataValid = true + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html new file mode 100644 index 00000000..6f412d9b --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -0,0 +1,29 @@ +
+
Cycle
+
{{ cycle?.name }}
+
+ + + + +
+
+
+
Access Level Info
+
+ + +
+ +@if (rowData.length) { + + +} \ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts new file mode 100644 index 00000000..4d50ec7a --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -0,0 +1,97 @@ +import { CommonModule } from '@angular/common' +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { Component, EventEmitter, inject, Input, Output } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatDividerModule } from '@angular/material/divider' +import type { Column } from '@seed/api/column' +import type { Cycle } from '@seed/api/cycle' +import type { MappingResultsResponse } from '@seed/api/dataset' +import type { Organization } from '@seed/api/organization' +import { ConfigService } from '@seed/services' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { Subject } from 'rxjs' + +@Component({ + selector: 'seed-save-mappings', + templateUrl: './save-mappings.component.html', + imports: [ + AgGridAngular, + CommonModule, + MatDividerModule, + MatButtonModule, + ], +}) +export class SaveMappingsComponent implements OnChanges, OnDestroy { + @Input() columns: Column[] + @Input() cycle: Cycle + @Input() mappingResultsResponse: MappingResultsResponse + @Input() org: Organization + @Input() orgId: number + @Output() completed = new EventEmitter() + + private _configService = inject(ConfigService) + private _unsubscribeAll$ = new Subject() + columnDefs: ColDef[] = [] + rowData: Record[] = [] + gridApi: GridApi + gridTheme$ = this._configService.gridTheme$ + mappingResults: Record[] = [] + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.mappingResultsResponse?.currentValue) return + + const { properties, tax_lots } = this.mappingResultsResponse + this.mappingResults = tax_lots.length ? tax_lots : properties || [] + this.setGrid() + } + + setGrid() { + this.setColumnDefs() + this.setRowData() + } + + setColumnDefs() { + const aliClass = 'bg-primary bg-opacity-25' + + let keys = Object.keys(this.mappingResults[0] ?? {}) + // remove ALI & hidden cols + const excludeKeys = ['id', 'lot_number', 'raw_access_level_instance_error', ...this.org.access_level_names] + keys = keys.filter((k) => !excludeKeys.includes(k)) + + const hiddenColumnDefs = [ + { field: 'id', hide: true }, + { field: 'lot_number', hide: true }, + ] + + // ALI columns + const aliErrorDef = { field: 'raw_access_level_instance_error', headerName: 'Access Level Error', cellClass: aliClass } + let aliColumnDefs = this.org.access_level_names.map((name) => ({ field: name, cellClass: aliClass })) + aliColumnDefs = [aliErrorDef, ...aliColumnDefs] + + // Inventory Columns + const columnNameMap: Record = this.columns.reduce((acc, { name, display_name }) => ({ ...acc, [name]: display_name }), {}) + const inventoryColumnDefs = keys.map((key) => ({ field: key, headerName: columnNameMap[key] || key })) + + this.columnDefs = [...hiddenColumnDefs, ...aliColumnDefs, ...inventoryColumnDefs] + } + + setRowData() { + this.rowData = this.mappingResults + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + } + + saveData() { + console.log('Saving data...') + console.log(this.mappingResults) + this.completed.emit() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html b/src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html new file mode 100644 index 00000000..86cbcb42 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html @@ -0,0 +1,3 @@ +
+ STEP 4! +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts b/src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts new file mode 100644 index 00000000..936a6913 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts @@ -0,0 +1,30 @@ +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' +import { Component } from '@angular/core' +import { ProgressBarComponent } from '@seed/components' +import { Subject } from 'rxjs' + +@Component({ + selector: 'seed-match-merge', + templateUrl: './match-marge.component.html', + imports: [ + ProgressBarComponent, + ], +}) +export class MatchMergeComponent implements OnChanges, OnDestroy { + private readonly _unsubscribeAll$ = new Subject() + + ngOnChanges(changes: SimpleChanges): void { + if (changes) { // what changes? + // do something + } + } + + startMatchMerge() { + console.log('start match merge') + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} From b3517a1c61c1d179bad40c6d45a10b32a4a1b288 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 2 Jul 2025 15:52:08 +0000 Subject: [PATCH 18/50] uploaded data, step4 issues --- .../api/data-quality/data-quality.service.ts | 20 ++++++ src/@seed/api/mapping/mapping.service.ts | 25 +++++++- src/@seed/api/progress/progress.types.ts | 10 +++ .../services/uploader/uploader.service.ts | 13 ++++ .../data-mappings/data-mapping.component.html | 13 ++-- .../data-mappings/data-mapping.component.ts | 24 ++++--- .../step3/save-mappings.component.html | 5 +- .../step3/save-mappings.component.ts | 43 ++++++++++++- .../step4/match-marge.compponent.html | 3 - .../step4/match-margecompponent.ts | 30 --------- .../step4/match-merge.component.html | 7 +++ .../step4/match-merge.component.ts | 63 +++++++++++++++++++ 12 files changed, 201 insertions(+), 55 deletions(-) delete mode 100644 src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html delete mode 100644 src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts create mode 100644 src/app/modules/datasets/data-mappings/step4/match-merge.component.html create mode 100644 src/app/modules/datasets/data-mappings/step4/match-merge.component.ts diff --git a/src/@seed/api/data-quality/data-quality.service.ts b/src/@seed/api/data-quality/data-quality.service.ts index 7fa0281d..9202dc71 100644 --- a/src/@seed/api/data-quality/data-quality.service.ts +++ b/src/@seed/api/data-quality/data-quality.service.ts @@ -5,6 +5,7 @@ import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { Rule } from './data-quality.types' +import { DQCProgressResponse } from '../progress' @Injectable({ providedIn: 'root' }) export class DataQualityService { @@ -79,4 +80,23 @@ export class DataQualityService { }), ) } + + startDataQualityCheckForImportFile(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/start_data_quality_checks/?organization_id=${orgId}` + return this._httpClient.post(url, {}) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting data quality checks for import file') + }), + ) + } + + getDataQualityResults(orgId: number, runId: number): Observable { + const url = `/api/v3/data_quality_checks/results/?organization_id=${orgId}&run_id=${runId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching data quality results') + }), + ) + } } diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index 78de21fc..5d0b725f 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -5,7 +5,7 @@ import { UserService } from '../user' import { catchError, map, tap, type Observable } from 'rxjs' import type { FirstFiveRowsResponse, MappingSuggestionsResponse, RawColumnNamesResponse } from './mapping.types' import { MappedData, MappingResultsResponse } from '../dataset' -import { ProgressResponse } from '../progress' +import { ProgressResponse, SubProgressResponse } from '../progress' @Injectable({ providedIn: 'root' }) export class MappingService { @@ -77,4 +77,27 @@ export class MappingService { }), ) } + + mappingDone(orgId: number, importFileId: number): Observable<{ message: string; status: string }> { + const url = `/api/v3/import_files/${importFileId}/mapping_done/?organization_id=${orgId}` + return this._httpClient.post<{ message: string; status: string }>(url, {}) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching mapping results') + }), + ) + } + + startMatchMerge(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/start_system_matching_and_geocoding/?organization_id=${orgId}` + return this._httpClient.post(url, {}) + .pipe( + tap((response) => { + console.log('Match merge started:', response) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting match merge') + }), + ) + } } diff --git a/src/@seed/api/progress/progress.types.ts b/src/@seed/api/progress/progress.types.ts index 5b90543e..865af90b 100644 --- a/src/@seed/api/progress/progress.types.ts +++ b/src/@seed/api/progress/progress.types.ts @@ -14,3 +14,13 @@ export type ProgressResponse = { total_records?: number; completed_records?: number; } + +export type DQCProgressResponse = { + progress: ProgressResponse; + progress_key: string; +} + +export type SubProgressResponse = { + progress_data: ProgressResponse; + sub_progress_data: ProgressResponse; +} diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index 34b807ee..c676e608 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.service.ts @@ -8,6 +8,7 @@ import { ErrorService } from '../error' import type { CheckProgressLoopParams, GreenButtonMeterPreview, + ProgressBarObj, SensorPreviewResponse, SensorReadingPreview, UpdateProgressBarObjParams, @@ -19,6 +20,18 @@ export class UploaderService { private _httpClient = inject(HttpClient) private _errorService = inject(ErrorService) + get defaultProgressBarObj(): ProgressBarObj { + return { + message: [], + progress: 0, + total: 100, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + } + /* * Checks a progress key for updates until it completes */ diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index e24d0927..07bd686f 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -47,7 +47,7 @@ @@ -55,16 +55,21 @@ - + abc + + xyz diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index 1cc33d99..aec6041a 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -32,7 +32,7 @@ import type { Profile } from 'app/modules/inventory' import { HelpComponent } from './help.component' import { MapDataComponent } from './step1/map-data.component' import { SaveMappingsComponent } from './step3/save-mappings.component' -import { MatchMergeProgressComponent } from './step4/match-margecompponent' +import { MatchMergeComponent } from './step4/match-merge.component' @Component({ selector: 'seed-data-mapping-stepper', @@ -43,7 +43,7 @@ import { MatchMergeProgressComponent } from './step4/match-margecompponent' FormsModule, HelpComponent, MapDataComponent, - MatchMergeProgressComponent, + MatchMergeComponent, MatButtonModule, MatButtonToggleModule, MatDividerModule, @@ -60,6 +60,7 @@ import { MatchMergeProgressComponent } from './step4/match-margecompponent' export class DataMappingComponent implements OnDestroy, OnInit { @ViewChild('stepper') stepper!: MatStepper @ViewChild(MapDataComponent) mapDataComponent!: MapDataComponent + @ViewChild(MatchMergeComponent) matchMergeComponent!: MatchMergeComponent private readonly _unsubscribeAll$ = new Subject() private _columnService = inject(ColumnService) private _cycleService = inject(CycleService) @@ -89,15 +90,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { rawColumnNames: string[] = [] taxlotColumns: Column[] - progressBarObj: ProgressBarObj = { - message: [], - progress: 0, - total: 100, - complete: false, - statusMessage: '', - progressLastUpdated: null, - progressLastChecked: null, - } + progressBarObj = this._uploaderService.defaultProgressBarObj ngOnInit(): void { // this._userService.currentOrganizationId$ @@ -221,8 +214,13 @@ export class DataMappingComponent implements OnDestroy, OnInit { .subscribe() } - nextStep(step: number) { - this.completed[step] = true + startMatchMerge() { + this.nextStep(3) + this.matchMergeComponent.startMatchMerge() + } + + nextStep(currentStep: number) { + this.completed[currentStep] = true setTimeout(() => { this.stepper.next() }) diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html index 6f412d9b..b1cb0941 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -12,7 +12,10 @@
Access Level Info
- +
+ + +
@if (rowData.length) { diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 4d50ec7a..6046029a 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -5,12 +5,14 @@ import { MatButtonModule } from '@angular/material/button' import { MatDividerModule } from '@angular/material/divider' import type { Column } from '@seed/api/column' import type { Cycle } from '@seed/api/cycle' -import type { MappingResultsResponse } from '@seed/api/dataset' +import { DataQualityService } from '@seed/api/data-quality'; +import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import type { Organization } from '@seed/api/organization' import { ConfigService } from '@seed/services' +import { ProgressBarObj, UploaderService } from '@seed/services/uploader'; import { AgGridAngular } from 'ag-grid-angular' import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import { Subject } from 'rxjs' +import { Subject, switchMap, take } from 'rxjs' @Component({ selector: 'seed-save-mappings', @@ -25,27 +27,58 @@ import { Subject } from 'rxjs' export class SaveMappingsComponent implements OnChanges, OnDestroy { @Input() columns: Column[] @Input() cycle: Cycle + @Input() importFile: ImportFile @Input() mappingResultsResponse: MappingResultsResponse @Input() org: Organization @Input() orgId: number @Output() completed = new EventEmitter() private _configService = inject(ConfigService) + private _dataQualityService = inject(DataQualityService) + private _uploaderService = inject(UploaderService) private _unsubscribeAll$ = new Subject() columnDefs: ColDef[] = [] rowData: Record[] = [] gridApi: GridApi gridTheme$ = this._configService.gridTheme$ mappingResults: Record[] = [] + dqcComplete = false + + progressBarObj = this._uploaderService.defaultProgressBarObj ngOnChanges(changes: SimpleChanges): void { if (!changes.mappingResultsResponse?.currentValue) return const { properties, tax_lots } = this.mappingResultsResponse this.mappingResults = tax_lots.length ? tax_lots : properties || [] + this.startDQC() this.setGrid() } + startDQC() { + const successFn = () => { + this.dqcComplete = true + } + // eslint-disable-next-line @typescript-eslint/no-empty-function + const failureFn = () => {} + + this._dataQualityService.startDataQualityCheckForImportFile(this.orgId, this.importFile.id) + .pipe( + take(1), + switchMap(({ progress_key }) => { + return this._uploaderService.checkProgressLoop({ + progressKey: progress_key, + offset: 0, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + }), + ) + .subscribe() + } + setGrid() { this.setColumnDefs() this.setRowData() @@ -86,10 +119,14 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { saveData() { console.log('Saving data...') - console.log(this.mappingResults) + // console.log(this.mappingResults) this.completed.emit() } + showDataQualityResults() { + console.log('open modal showing dqc results') + } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html b/src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html deleted file mode 100644 index 86cbcb42..00000000 --- a/src/app/modules/datasets/data-mappings/step4/match-marge.compponent.html +++ /dev/null @@ -1,3 +0,0 @@ -
- STEP 4! -
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts b/src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts deleted file mode 100644 index 936a6913..00000000 --- a/src/app/modules/datasets/data-mappings/step4/match-margecompponent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' -import { Component } from '@angular/core' -import { ProgressBarComponent } from '@seed/components' -import { Subject } from 'rxjs' - -@Component({ - selector: 'seed-match-merge', - templateUrl: './match-marge.component.html', - imports: [ - ProgressBarComponent, - ], -}) -export class MatchMergeComponent implements OnChanges, OnDestroy { - private readonly _unsubscribeAll$ = new Subject() - - ngOnChanges(changes: SimpleChanges): void { - if (changes) { // what changes? - // do something - } - } - - startMatchMerge() { - console.log('start match merge') - } - - ngOnDestroy(): void { - this._unsubscribeAll$.next() - this._unsubscribeAll$.complete() - } -} diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html new file mode 100644 index 00000000..fa3a8031 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -0,0 +1,7 @@ +
+ +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts new file mode 100644 index 00000000..652055cc --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -0,0 +1,63 @@ +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' +import { Component, inject, Input } from '@angular/core' +import { DataQualityService } from '@seed/api/data-quality' +import { MappingService } from '@seed/api/mapping' +import { SubProgressResponse } from '@seed/api/progress' +import { ProgressBarComponent } from '@seed/components' +import { ProgressBarObj, UploaderService } from '@seed/services/uploader' +import { EMPTY, Subject, switchMap, take } from 'rxjs' + +@Component({ + selector: 'seed-match-merge', + templateUrl: './match-merge.component.html', + imports: [ + ProgressBarComponent, + ], +}) +export class MatchMergeComponent implements OnDestroy { + @Input() importFileId: number + @Input() orgId: number + + private _mappingService = inject(MappingService) + private _uploaderService = inject(UploaderService) + private readonly _unsubscribeAll$ = new Subject() + + progressBarObj = this._uploaderService.defaultProgressBarObj + subProgressBarObj = this._uploaderService.defaultProgressBarObj + + startMatchMerge() { + this._mappingService.mappingDone(this.orgId, this.importFileId) + .pipe( + take(1), + switchMap(() => this._mappingService.startMatchMerge(this.orgId, this.importFileId)), + take(1), + switchMap((data) => this.checkProgress(data)), + ) + .subscribe() + } + + checkProgress(data: SubProgressResponse) { + const successFn = () => { + console.log('success') + } + const failureFn = () => { + console.log('failure') + } + + const { progress_data } = data + + return this._uploaderService.checkProgressLoop({ + progressKey: progress_data.progress_key, + offset: 0, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} From bd0eb0be217b6b5be738258d7991c3b645c32c9f Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 2 Jul 2025 16:24:37 +0000 Subject: [PATCH 19/50] link to ptl step4 --- .../data-mappings/data-mapping.component.html | 4 ++-- .../data-mappings/data-mapping.component.ts | 13 ++++++++---- .../step3/save-mappings.component.ts | 18 +++++++++++++---- .../step4/match-merge.component.html | 20 ++++++++++++++----- .../step4/match-merge.component.ts | 16 +++++++++++---- 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 07bd686f..cb7092e9 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -60,16 +60,16 @@ [org]="org" [orgId]="orgId" (completed)="startMatchMerge()" + (inventoryTypeChange)="onInventoryTypeChange($event)" > - abc - xyz diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index aec6041a..88378d5c 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -14,21 +14,21 @@ import { MatStepperModule } from '@angular/material/stepper' import { ActivatedRoute } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import { catchError, filter, forkJoin, of, Subject, switchMap, take, tap } from 'rxjs' -import { ColumnService, type Column } from '@seed/api/column' +import { type Column, ColumnService } from '@seed/api/column' import type { Cycle } from '@seed/api/cycle' import { CycleService } from '@seed/api/cycle/cycle.service' import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import { DatasetService } from '@seed/api/dataset' import type { MappingSuggestionsResponse } from '@seed/api/mapping' import { MappingService } from '@seed/api/mapping' -import { Organization, OrganizationService } from '@seed/api/organization' +import type { Organization } from '@seed/api/organization'; +import { OrganizationService } from '@seed/api/organization' import type { ProgressResponse } from '@seed/api/progress' import { UserService } from '@seed/api/user' import { PageComponent, ProgressBarComponent } from '@seed/components' -import type { ProgressBarObj } from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { Profile } from 'app/modules/inventory' +import type { InventoryType, Profile } from 'app/modules/inventory' import { HelpComponent } from './help.component' import { MapDataComponent } from './step1/map-data.component' import { SaveMappingsComponent } from './step3/save-mappings.component' @@ -80,6 +80,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { firstFiveRows: Record[] helpOpened = false importFile: ImportFile + inventoryType: InventoryType = 'properties' mappingResultsResponse: MappingResultsResponse mappingSuggestions: MappingSuggestionsResponse matchingPropertyColumns: string[] = [] @@ -226,6 +227,10 @@ export class DataMappingComponent implements OnDestroy, OnInit { }) } + onInventoryTypeChange(inventoryType: InventoryType) { + this.inventoryType = inventoryType + } + toggleHelp = () => { this.helpOpened = !this.helpOpened } diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 6046029a..11fd2f00 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -3,16 +3,17 @@ import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MatDividerModule } from '@angular/material/divider' +import { AgGridAngular } from 'ag-grid-angular' +import { Subject, switchMap, take } from 'rxjs' import type { Column } from '@seed/api/column' import type { Cycle } from '@seed/api/cycle' import { DataQualityService } from '@seed/api/data-quality'; import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import type { Organization } from '@seed/api/organization' import { ConfigService } from '@seed/services' -import { ProgressBarObj, UploaderService } from '@seed/services/uploader'; -import { AgGridAngular } from 'ag-grid-angular' +import { UploaderService } from '@seed/services/uploader' import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import { Subject, switchMap, take } from 'rxjs' +import type { InventoryType } from 'app/modules/inventory' @Component({ selector: 'seed-save-mappings', @@ -32,6 +33,7 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { @Input() org: Organization @Input() orgId: number @Output() completed = new EventEmitter() + @Output() inventoryTypeChange = new EventEmitter() private _configService = inject(ConfigService) private _dataQualityService = inject(DataQualityService) @@ -43,6 +45,7 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { gridTheme$ = this._configService.gridTheme$ mappingResults: Record[] = [] dqcComplete = false + inventoryType: InventoryType progressBarObj = this._uploaderService.defaultProgressBarObj @@ -50,7 +53,14 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { if (!changes.mappingResultsResponse?.currentValue) return const { properties, tax_lots } = this.mappingResultsResponse - this.mappingResults = tax_lots.length ? tax_lots : properties || [] + if (tax_lots.length) { + this.mappingResults = tax_lots + this.inventoryTypeChange.emit('taxlots') + } else { + this.mappingResults = properties + this.inventoryTypeChange.emit('properties') + } + this.startDQC() this.setGrid() } diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html index fa3a8031..efbf94d5 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -1,7 +1,17 @@
- + @if (inProgress) { + + } @else { + + }
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index 652055cc..e71257fb 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -1,26 +1,33 @@ -import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' +import { CommonModule } from '@angular/common' +import type { OnDestroy } from '@angular/core' import { Component, inject, Input } from '@angular/core' -import { DataQualityService } from '@seed/api/data-quality' +import { MatButtonModule } from '@angular/material/button' +import { RouterModule } from '@angular/router' import { MappingService } from '@seed/api/mapping' import { SubProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' -import { ProgressBarObj, UploaderService } from '@seed/services/uploader' -import { EMPTY, Subject, switchMap, take } from 'rxjs' +import { UploaderService } from '@seed/services/uploader' +import { Subject, switchMap, take } from 'rxjs' @Component({ selector: 'seed-match-merge', templateUrl: './match-merge.component.html', imports: [ + CommonModule, + MatButtonModule, ProgressBarComponent, + RouterModule, ], }) export class MatchMergeComponent implements OnDestroy { @Input() importFileId: number @Input() orgId: number + @Input() inventoryType private _mappingService = inject(MappingService) private _uploaderService = inject(UploaderService) private readonly _unsubscribeAll$ = new Subject() + inProgress = true progressBarObj = this._uploaderService.defaultProgressBarObj subProgressBarObj = this._uploaderService.defaultProgressBarObj @@ -39,6 +46,7 @@ export class MatchMergeComponent implements OnDestroy { checkProgress(data: SubProgressResponse) { const successFn = () => { console.log('success') + this.inProgress = false } const failureFn = () => { console.log('failure') From e0dae9a2d18960100b70384d5664d8d0895b3642 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 3 Jul 2025 16:00:40 +0000 Subject: [PATCH 20/50] subprogress fxnal --- .../progress/progress-bar.component.html | 37 ++++++++++++---- .../progress/progress-bar.component.ts | 35 +++++++++++---- .../services/uploader/uploader.service.ts | 43 +++++++++++++++--- src/@seed/services/uploader/uploader.types.ts | 1 + .../data-mappings/data-mapping.component.ts | 9 +++- .../step3/save-mappings.component.html | 25 ++++++++++- .../step3/save-mappings.component.ts | 8 +++- .../step4/match-merge.component.html | 4 ++ .../step4/match-merge.component.ts | 44 ++++++++++++------- 9 files changed, 164 insertions(+), 42 deletions(-) diff --git a/src/@seed/components/progress/progress-bar.component.html b/src/@seed/components/progress/progress-bar.component.html index 1a4c5ed5..d8aa727f 100644 --- a/src/@seed/components/progress/progress-bar.component.html +++ b/src/@seed/components/progress/progress-bar.component.html @@ -1,13 +1,34 @@
-
-
- -
{{ title }}
+
+
+
+ +
{{ title }}
+
+ @if (progress) { +
+ {{ progressString }} +
+ }
- @if (progressMode === 'determinate') { -
{{ progress | number: '1.0-0' }} / {{ total | number: '1.0-0' }}
- } + +
- + @if (showSubProgress && subProgress && subProgress < 100) { +
+
+
+
{{ subTitle }}
+
+ @if (subProgress) { +
+ {{ subProgressString }} +
+ } +
+ + +
+ }
diff --git a/src/@seed/components/progress/progress-bar.component.ts b/src/@seed/components/progress/progress-bar.component.ts index 50f8c4ce..2cf0b5af 100644 --- a/src/@seed/components/progress/progress-bar.component.ts +++ b/src/@seed/components/progress/progress-bar.component.ts @@ -1,31 +1,50 @@ import { CommonModule } from '@angular/common' import { Component, Input } from '@angular/core' +import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' -import type { ProgressBarMode } from '@angular/material/progress-bar' import { MatProgressBarModule } from '@angular/material/progress-bar' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' @Component({ selector: 'seed-progress-bar', templateUrl: './progress-bar.component.html', - imports: [CommonModule, MatProgressBarModule, MatIconModule], + imports: [CommonModule, MatDividerModule, MatProgressBarModule, MatIconModule, MatProgressSpinnerModule], }) export class ProgressBarComponent { @Input() total: number @Input() progress: number @Input() title: string @Input() outline = false + @Input() showSubProgress? = false + @Input() subProgress?: number + @Input() subTotal?: number + @Input() subTitle?: string get percent() { return (this.progress / this.total) * 100 } - get showNumericProgress() { - if (this.progressMode === 'indeterminate') return false - return this.progress && this.progress < this.total + get progressString() { + return this.getProgressString(this.total, this.progress) } - get progressMode() { - const mode = this.progress ? 'determinate' : 'indeterminate' - return mode as ProgressBarMode + get subProgressString() { + if (!this.showSubProgress) return + return this.getProgressString(this.subTotal, this.subProgress) + } + + get subPercent() { + return this.subTotal ? (this.subProgress / this.subTotal) * 100 : undefined + } + + getProgressMode(progress) { + return progress ? 'determinate' : 'indeterminate' + } + + getProgressString(totalFloat: number, progressFloat: number) { + const total = Math.round(totalFloat) + const progress = Math.round(progressFloat) + const suffix = total === 100 ? '%' : `/ ${total}` + return `${progress} ${suffix}` } } diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index c676e608..b1cb1aae 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.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 { catchError, interval, of, switchMap, takeWhile, tap, throwError } from 'rxjs' +import { catchError, combineLatest, filter, finalize, interval, map, of, repeat, startWith, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' import type { ProgressResponse } from '@seed/api/progress' import { ErrorService } from '../error' import type { @@ -42,16 +42,14 @@ export class UploaderService { successFn, failureFn, progressBarObj, + subProgress = false, }: CheckProgressLoopParams): Observable { const isCompleted = (status: string) => ['error', 'success', 'warning'].includes(status) - return interval(750).pipe( + let progressLoop$ = interval(750).pipe( switchMap(() => this.checkProgress(progressKey)), tap((response) => { this._updateProgressBarObj({ data: response, offset, multiplier, progressBarObj }) - }), - takeWhile((response) => !isCompleted(response.status), true), // end stream - tap((response) => { if (response.status === 'success') successFn() }), catchError(() => { @@ -60,6 +58,39 @@ export class UploaderService { return throwError(() => new Error('Progress check failed')) }), ) + + // subProgress loops run until parent progress completes + if (!subProgress) { + progressLoop$ = progressLoop$.pipe( + takeWhile((response) => !isCompleted(response.status), true), // end stream + ) + } + + return progressLoop$ + } + + /* + * Check the progress of Main Progress and its Sub Progress + * Main progress will run until it completes + * Sub Progresses can complete several times and will run continuously until Main Progress is completed + * the stop$ stream is used to end the Sub Progress stream + */ + checkProgressLoopMainSub(mainParams: CheckProgressLoopParams, subParams: CheckProgressLoopParams) { + const stop$ = new Subject() + const main$ = this.checkProgressLoop(mainParams) + .pipe( + finalize(() => { + stop$.next() + stop$.complete() + }), + ) + + const sub$ = this.checkProgressLoop({ ...subParams, subProgress: true }) + .pipe( + takeUntil(stop$), + ) + + return combineLatest([main$, sub$]) } /* @@ -75,6 +106,8 @@ export class UploaderService { ) } + + greenButtonMetersPreview(orgId: number, viewId: number, systemId: number, fileId: number): Observable { const url = `/api/v3/import_files/${fileId}/greenbutton_meters_preview/` const params: Record = { organization_id: orgId } diff --git a/src/@seed/services/uploader/uploader.types.ts b/src/@seed/services/uploader/uploader.types.ts index ac192eac..93dbf45a 100644 --- a/src/@seed/services/uploader/uploader.types.ts +++ b/src/@seed/services/uploader/uploader.types.ts @@ -20,6 +20,7 @@ export type CheckProgressLoopParams = { successFn: () => void; failureFn: () => void; progressBarObj: ProgressBarObj; + subProgress?: boolean; } export type UpdateProgressBarObjParams = { diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index ec269363..1f2f47c6 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -28,7 +28,7 @@ import { UploaderService } from '@seed/services/uploader' import { AgGridAngular } from 'ag-grid-angular' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType, Profile } from 'app/modules/inventory' -import { catchError, filter, forkJoin, of, Subject, switchMap, take, tap } from 'rxjs' +import { catchError, filter, forkJoin, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import { HelpComponent } from './help.component' import { MapDataComponent } from './step1/map-data.component' import { SaveMappingsComponent } from './step3/save-mappings.component' @@ -182,12 +182,16 @@ export class DataMappingComponent implements OnDestroy, OnInit { this._mappingService.startMapping(this.orgId, this.fileId, mappedData) .pipe( - take(1), switchMap(() => this._mappingService.remapBuildings(this.orgId, this.fileId)), tap((response: ProgressResponse) => { this.progressBarObj.progress = response.progress }), switchMap((data) => { + if (data.progress === 100) { + successFn() + return of(null) + } + return this._uploaderService.checkProgressLoop({ progressKey: data.progress_key, offset: 0, @@ -197,6 +201,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { progressBarObj: this.progressBarObj, }) }), + takeUntil(this._unsubscribeAll$), catchError((error) => { console.log('Error starting mapping:', error) return of(null) diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html index b1cb0941..dcef41f0 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -13,8 +13,20 @@
- - + +
@@ -29,4 +41,13 @@ (gridReady)="onGridReady($event)" >
+} @else { +
+
+ +
Fetching Mapping Results...
+
+ +
+ } \ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 09f7bf38..65bb5c15 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -3,9 +3,11 @@ import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +import { MatProgressBarModule } from '@angular/material/progress-bar' import type { Column } from '@seed/api/column' import type { Cycle } from '@seed/api/cycle' -import { DataQualityService } from '@seed/api/data-quality'; +import { DataQualityService } from '@seed/api/data-quality' import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import type { Organization } from '@seed/api/organization' import { ConfigService } from '@seed/services' @@ -21,8 +23,10 @@ import { Subject, switchMap, take } from 'rxjs' imports: [ AgGridAngular, CommonModule, - MatDividerModule, MatButtonModule, + MatDividerModule, + MatIconModule, + MatProgressBarModule, ], }) export class SaveMappingsComponent implements OnChanges, OnDestroy { diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html index efbf94d5..fc40ebd0 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -4,6 +4,10 @@ [progress]="progressBarObj.progress" [total]="progressBarObj.total" [title]="progressBarObj.statusMessage" + [showSubProgress]="true" + [subProgress]="subProgressBarObj.progress" + [subTitle]="subProgressBarObj.statusMessage" + [subTotal]="subProgressBarObj.total" > } @else {
diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index e71257fb..3e2a06cd 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -4,10 +4,11 @@ import { Component, inject, Input } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { RouterModule } from '@angular/router' import { MappingService } from '@seed/api/mapping' -import { SubProgressResponse } from '@seed/api/progress' +import type { SubProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' +import type { CheckProgressLoopParams} from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' -import { Subject, switchMap, take } from 'rxjs' +import { finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-match-merge', @@ -35,33 +36,46 @@ export class MatchMergeComponent implements OnDestroy { startMatchMerge() { this._mappingService.mappingDone(this.orgId, this.importFileId) .pipe( - take(1), switchMap(() => this._mappingService.startMatchMerge(this.orgId, this.importFileId)), - take(1), switchMap((data) => this.checkProgress(data)), + takeUntil(this._unsubscribeAll$), ) .subscribe() } checkProgress(data: SubProgressResponse) { const successFn = () => { - console.log('success') this.inProgress = false } - const failureFn = () => { - console.log('failure') - } - - const { progress_data } = data - return this._uploaderService.checkProgressLoop({ + const { progress_data, sub_progress_data } = data + const baseParams = { offset: 0, multiplier: 1 } + const mainParams: CheckProgressLoopParams = { progressKey: progress_data.progress_key, - offset: 0, - multiplier: 1, successFn, - failureFn, + failureFn: () => void 0, progressBarObj: this.progressBarObj, - }) + ...baseParams, + } + + const subParams: CheckProgressLoopParams = { + progressKey: sub_progress_data.progress_key, + successFn: () => void 0, + failureFn: () => void 0, + progressBarObj: this.subProgressBarObj, + ...baseParams, + } + + return this._uploaderService.checkProgressLoopMainSub(mainParams, subParams) + .pipe( + tap(([_, subProgress]) => { + console.log('subProgress', subProgress.status_message, this.subProgressBarObj.statusMessage, this.subProgressBarObj.progress) + }), + finalize(() => { + console.log('final main progressBarObj', this.progressBarObj) + console.log('final sub progressBarObj', this.subProgressBarObj) + }), + ) } ngOnDestroy(): void { From f2d8998d2ee97cac8dbf4478052f9e25c88e89d5 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 3 Jul 2025 19:14:43 +0000 Subject: [PATCH 21/50] whole xls upload fxnal --- src/@seed/api/mapping/mapping.service.ts | 22 +++- src/@seed/api/mapping/mapping.types.ts | 26 ++++ .../step1/map-data.component.html | 18 +-- .../step4/match-merge.component.html | 13 +- .../step4/match-merge.component.ts | 27 ++-- .../step4/results.component.html | 66 ++++++++++ .../data-mappings/step4/results.component.ts | 119 ++++++++++++++++++ 7 files changed, 259 insertions(+), 32 deletions(-) create mode 100644 src/app/modules/datasets/data-mappings/step4/results.component.html create mode 100644 src/app/modules/datasets/data-mappings/step4/results.component.ts diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index 44197043..5e09ff0c 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -1,11 +1,12 @@ -import { HttpClient, HttpErrorResponse } from '@angular/common/http' +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 { UserService } from '../user' import { catchError, map, tap, type Observable } from 'rxjs' -import type { FirstFiveRowsResponse, MappingSuggestionsResponse, RawColumnNamesResponse } from './mapping.types' +import type { FirstFiveRowsResponse, MappingSuggestionsResponse, MatchingResultsResponse, RawColumnNamesResponse } from './mapping.types' import { MappedData, MappingResultsResponse } from '../dataset' -import { ProgressResponse, SubProgressResponse } from '../progress' +import type { ProgressResponse, SubProgressResponse } from '../progress' @Injectable({ providedIn: 'root' }) export class MappingService { @@ -88,9 +89,10 @@ export class MappingService { ) } - startMatchMerge(orgId: number, importFileId: number): Observable { + startMatchMerge(orgId: number, importFileId: number): Observable { const url = `/api/v3/import_files/${importFileId}/start_system_matching_and_geocoding/?organization_id=${orgId}` - return this._httpClient.post(url, {}) + // returns ProgressResponse if already matched + return this._httpClient.post(url, {}) .pipe( tap((response) => { console.log('Match merge started:', response) @@ -100,4 +102,14 @@ export class MappingService { }), ) } + + getMatchingResults(orgId: number, importFileId: number): Observable { + const url = `/api/v3/import_files/${importFileId}/matching_and_geocoding_results/?organization_id=${orgId}` + return this._httpClient.get(url) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error getting matching and geocoding results') + }), + ) + } } diff --git a/src/@seed/api/mapping/mapping.types.ts b/src/@seed/api/mapping/mapping.types.ts index 170a7d78..c5db1412 100644 --- a/src/@seed/api/mapping/mapping.types.ts +++ b/src/@seed/api/mapping/mapping.types.ts @@ -18,3 +18,29 @@ export type FirstFiveRowsResponse = { status: string; first_five_rows: Record[]; } + +export type MatchingResultsResponse = { + import_file_records: number; + multiple_cycle_upload: boolean; + properties: MatchingResults; + tax_lots: MatchingResults; +} + +export type MatchingResults = { + duplicates_against_existing: number; + duplicates_within_file: number; + duplicates_within_file_errors: number; + geocode_not_possible: number; + geocoded_census_geocoder: number; + geocoded_high_confidence: number; + geocoded_low_confidence: number; + geocoded_manually: number; + initial_incoming: number; + merges_against_existing: number; + merges_against_existing_errors: number; + merges_between_existing: number; + merges_within_file: number; + merges_within_file_errors: number; + new: number; + new_errors: number; +} 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 7e3ae3ec..a8ac3c5e 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 @@ -1,15 +1,17 @@ -
-
Cycle
-
{{ cycle?.name }}
-
-
-
Column Profile
-
none selected
+
+
+
Cycle:
+
{{ cycle?.name }}
+
+
+
Column Profile:
+
none selected
+
- + Property Types diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html index fc40ebd0..cb23f6f5 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -10,12 +10,11 @@ [subTotal]="subProgressBarObj.total" > } @else { - + }
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index 3e2a06cd..a357adea 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -3,12 +3,13 @@ import type { OnDestroy } from '@angular/core' import { Component, inject, Input } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { RouterModule } from '@angular/router' +import { of, Subject, switchMap, takeUntil } from 'rxjs' import { MappingService } from '@seed/api/mapping' -import type { SubProgressResponse } from '@seed/api/progress' +import type { ProgressResponse, SubProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' import type { CheckProgressLoopParams} from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' -import { finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' +import { ResultsComponent } from './results.component' @Component({ selector: 'seed-match-merge', @@ -18,6 +19,7 @@ import { finalize, Subject, switchMap, takeUntil, tap } from 'rxjs' MatButtonModule, ProgressBarComponent, RouterModule, + ResultsComponent, ], }) export class MatchMergeComponent implements OnDestroy { @@ -34,15 +36,25 @@ export class MatchMergeComponent implements OnDestroy { subProgressBarObj = this._uploaderService.defaultProgressBarObj startMatchMerge() { + this.inProgress = true this._mappingService.mappingDone(this.orgId, this.importFileId) .pipe( switchMap(() => this._mappingService.startMatchMerge(this.orgId, this.importFileId)), - switchMap((data) => this.checkProgress(data)), + switchMap((response) => this.checkProgressResponse(response)), takeUntil(this._unsubscribeAll$), ) .subscribe() } + checkProgressResponse(response: ProgressResponse | SubProgressResponse) { + // check if its already matched and skip progress step + if ((response as ProgressResponse).progress === 100) { + this.inProgress = false + return of(null) + } + return this.checkProgress(response as SubProgressResponse) + } + checkProgress(data: SubProgressResponse) { const successFn = () => { this.inProgress = false @@ -67,15 +79,6 @@ export class MatchMergeComponent implements OnDestroy { } return this._uploaderService.checkProgressLoopMainSub(mainParams, subParams) - .pipe( - tap(([_, subProgress]) => { - console.log('subProgress', subProgress.status_message, this.subProgressBarObj.statusMessage, this.subProgressBarObj.progress) - }), - finalize(() => { - console.log('final main progressBarObj', this.progressBarObj) - console.log('final sub progressBarObj', this.subProgressBarObj) - }), - ) } ngOnDestroy(): void { diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html new file mode 100644 index 00000000..8cc9c5e9 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -0,0 +1,66 @@ +
+ + @if ( matchingResults) { + + +
+ Records found: {{ matchingResults?.import_file_records }} +
+ } @else { +
+
+ +
Getting Results...
+
+ +
+ } + + @if (hasPropertyData) { +
+ +
+ +
+
+ + Properties +
+ + + +
+ } + + @if (hasTaxlotData) { + +
+
+ + Tax Lots +
+ + + +
+ } + + +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.ts b/src/app/modules/datasets/data-mappings/step4/results.component.ts new file mode 100644 index 00000000..2724e94e --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/results.component.ts @@ -0,0 +1,119 @@ +import { CommonModule } from '@angular/common' +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' +import { Component, inject, Input } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { RouterModule } from '@angular/router' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { Subject, takeUntil, tap } from 'rxjs' +import type { MatchingResultsResponse } from '@seed/api/mapping' +import { MappingService } from '@seed/api/mapping' +import { ConfigService } from '@seed/services' +import type { InventoryType } from 'app/modules/inventory' + +@Component({ + selector: 'seed-match-merge-results', + templateUrl: './results.component.html', + imports: [ + AgGridAngular, + CommonModule, + MatButtonModule, + MatIconModule, + MatDividerModule, + MatProgressBarModule, + RouterModule, + ], +}) +export class ResultsComponent implements OnChanges, OnDestroy { + @Input() importFileId: number + @Input() orgId: number + @Input() inventoryType: InventoryType + @Input() inProgress = true + + private _configService = inject(ConfigService) + private _mappingService = inject(MappingService) + private readonly _unsubscribeAll$ = new Subject() + + gridTheme$ = this._configService.gridTheme$ + generalData: Record[] + generalColDefs: ColDef[] = [] + inventoryColDefs: ColDef[] = [ + { field: 'status', headerName: 'Status' }, + { field: 'count', headerName: 'Count' }, + ] + matchingResults: MatchingResultsResponse + propertyData: Record[] = [] + taxlotData: Record[] = [] + hasPropertyData = false + hasTaxlotData = false + + ngOnChanges(changes: SimpleChanges): void { + console.log('changes', changes) + if (changes.inProgress.currentValue === false) { + this.getMatchingResults() + } + } + + getMatchingResults() { + this._mappingService.getMatchingResults(this.orgId, this.importFileId) + .pipe( + takeUntil(this._unsubscribeAll$), + tap((results) => { this.setGrid(results) }), + ) + .subscribe() + } + + setGrid(results: MatchingResultsResponse) { + this.matchingResults = results + this.setGeneralGrid() + this.setInventoryGrids() + } + + setGeneralGrid() { + this.generalColDefs = [ + { field: 'import_file_records', headerName: 'Records in File' }, + { field: 'multiple_cycle_upload', headerName: 'Multi Cycle Upload' }, + ] + const { import_file_records, multiple_cycle_upload } = this.matchingResults + this.generalData = [{ import_file_records, multiple_cycle_upload }] + } + + setInventoryGrids() { + this.setPropertyData() + this.setTaxLotData() + } + + setPropertyData() { + const { properties } = this.matchingResults + this.hasPropertyData = Object.values(properties).some((v) => v) + this.propertyData = Object.entries(properties) + .filter(([_, v]) => v) + .map(([k, v]) => ({ + status: this.readableString(k), + count: v, + })) + } + + setTaxLotData() { + const { tax_lots } = this.matchingResults + this.hasTaxlotData = Object.values(tax_lots).some((v) => v) + this.taxlotData = Object.entries(tax_lots) + .filter(([_, v]) => v) + .map(([k, v]) => ({ + status: this.readableString(k), + count: v, + })) + } + + readableString(str: string) { + return str.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} From cd6f9f0861e115a2dcd55dd270431291b378ca30 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 3 Jul 2025 19:20:23 +0000 Subject: [PATCH 22/50] styles --- src/@seed/components/progress/progress-bar.component.html | 2 +- .../data-mappings/step3/save-mappings.component.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/@seed/components/progress/progress-bar.component.html b/src/@seed/components/progress/progress-bar.component.html index d8aa727f..8ffeecdd 100644 --- a/src/@seed/components/progress/progress-bar.component.html +++ b/src/@seed/components/progress/progress-bar.component.html @@ -12,7 +12,7 @@ }
- +
@if (showSubProgress && subProgress && subProgress < 100) { diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html index dcef41f0..a5f07b0c 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -1,6 +1,6 @@ -
-
Cycle
-
{{ cycle?.name }}
+
+
Cycle:
+
{{ cycle?.name }}
From 75f08643be92ecf8386b581efdc31cb5c2bb3524 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 3 Jul 2025 19:28:40 +0000 Subject: [PATCH 23/50] autocomplete not subset --- src/@seed/components/ag-grid/autocomplete.component.ts | 3 +-- src/app/modules/datasets/data-mappings/step1/column-defs.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts index 1cfa9db2..c842afde 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.ts +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -6,7 +6,6 @@ import { MatFormFieldModule } from '@angular/material/form-field' import { MatInputModule } from '@angular/material/input' import type { ICellEditorAngularComp } from 'ag-grid-angular' import type { ICellEditorParams } from 'ag-grid-community' -import { isOrderedSubset } from '@seed/utils/string-matching.util' @Component({ selector: 'seed-ag-grid-auto-complete-cell', @@ -35,7 +34,7 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV this.inputCtrl.valueChanges.subscribe((value) => { // autocomplete this.filteredOptions = this.options.filter((option) => { - return isOrderedSubset(value, option) + return option.toLowerCase().startsWith(value.toLowerCase()) }) }) } diff --git a/src/app/modules/datasets/data-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts index 86113131..e1f01159 100644 --- a/src/app/modules/datasets/data-mappings/step1/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -1,6 +1,6 @@ +import type { CellValueChangedEvent, ColDef, ColGroupDef, ICellRendererParams } from 'ag-grid-community' import { EditHeaderComponent } from '@seed/components' import { AutocompleteCellComponent } from '@seed/components/ag-grid/autocomplete.component' -import type { CellValueChangedEvent, ColDef, ColGroupDef, ICellRendererParams } from 'ag-grid-community' import { dataTypeOptions, unitMap } from './constants' export const gridOptions = { From 90cc539286b013fee0d2b3ec6c8932eaea671307 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 3 Jul 2025 19:47:57 +0000 Subject: [PATCH 24/50] steps not editable --- .../datasets/data-mappings/data-mapping.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index cb7092e9..0016eab4 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -29,7 +29,7 @@ - + - + Date: Mon, 7 Jul 2025 14:38:37 +0000 Subject: [PATCH 25/50] select all count --- src/app/modules/inventory-list/list/grid/actions.component.ts | 2 ++ src/app/modules/inventory-list/list/inventory.component.html | 1 + src/app/modules/inventory-list/list/inventory.component.ts | 4 ++++ 3 files changed, 7 insertions(+) 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 46db90e6..36efb584 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -26,6 +26,7 @@ export class ActionsComponent implements OnDestroy { @Input() selectedViewIds: number[] @Input() type: InventoryType @Output() refreshInventory = new EventEmitter() + @Output() selectedAll = new EventEmitter() private _inventoryService = inject(InventoryService) private _dialog = inject(MatDialog) private readonly _unsubscribeAll$ = new Subject() @@ -95,6 +96,7 @@ export class ActionsComponent implements OnDestroy { const paramString = params.toString() this._inventoryService.getAgInventory(paramString, {}).subscribe(({ results }: { results: number[] }) => { this.selectedViewIds = results + this.selectedAll.emit(this.selectedViewIds) }) } diff --git a/src/app/modules/inventory-list/list/inventory.component.html b/src/app/modules/inventory-list/list/inventory.component.html index 06950a1f..62137c08 100644 --- a/src/app/modules/inventory-list/list/inventory.component.html +++ b/src/app/modules/inventory-list/list/inventory.component.html @@ -23,6 +23,7 @@ [selectedViewIds]="selectedViewIds" [type]="type" (refreshInventory)="refreshInventory$.next()" + (selectedAll)="onSelectAll($event)" > diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index fc590ca7..f84648ce 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -271,6 +271,10 @@ export class InventoryComponent implements OnDestroy, OnInit { this.selectedViewIds = this.gridApi.getSelectedRows().map(({ property_view_id }: { property_view_id: number }) => property_view_id) } + onSelectAll(selectedViewIds: number[]) { + this.selectedViewIds = selectedViewIds + } + onProfileChange(id: number) { this.profileId$.next(id) } From f380c2fcd5685d1718768add93c2ba0b46fdaf02 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 7 Jul 2025 14:42:40 +0000 Subject: [PATCH 26/50] rounded btns --- src/app/modules/datasets/dataset/dataset.component.ts | 4 ++-- src/app/modules/datasets/datasets.component.ts | 8 ++++---- .../sensors/data-loggers/data-loggers-grid.component.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 480108eb..799af594 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -95,11 +95,11 @@ export class DatasetComponent implements OnDestroy, OnInit { actionsRenderer() { return `
- + Data Mapping open_in_new - + Data Pairing open_in_new diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index 9d1acba2..4c10365d 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -8,15 +8,15 @@ import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import { filter, switchMap, tap } from 'rxjs' +import type { Cycle } from '@seed/api/cycle' +import { CycleService } from '@seed/api/cycle/cycle.service' import { type Dataset, DatasetService } from '@seed/api/dataset' import { UserService } from '@seed/api/user' import { DeleteModalComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { naturalSort } from '@seed/utils' -import { FormModalComponent } from './modal/form-modal.component' import { UploadFileModalComponent } from './data-upload/data-upload-modal.component' -import { CycleService } from '@seed/api/cycle/cycle.service' -import { Cycle } from '@seed/api/cycle' +import { FormModalComponent } from './modal/form-modal.component' @Component({ selector: 'seed-data', @@ -95,7 +95,7 @@ export class DatasetsComponent implements OnInit { actionsRenderer() { return `
- + add Data Files diff --git a/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts b/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts index ef37f423..678c8779 100644 --- a/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts +++ b/src/app/modules/inventory-detail/sensors/data-loggers/data-loggers-grid.component.ts @@ -74,11 +74,11 @@ export class DataLoggersGridComponent implements OnChanges { actionRenderer() { return `
- + add Sensors - + add Readings From 5aacc7b69418aee723188a8966c3176eeaf1fa5c Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 7 Jul 2025 17:03:17 +0000 Subject: [PATCH 27/50] styles --- .../ag-grid/autocomplete.component.html | 1 - .../ag-grid/autocomplete.component.ts | 8 ------ .../step1/map-data.component.html | 27 ++++++++++++++---- .../data-mappings/step1/map-data.component.ts | 26 +++++++++++------ .../datasets/dataset/dataset.component.ts | 2 +- .../modal/more-actions-modal.component.html | 8 ++++-- .../modal/more-actions-modal.component.ts | 28 ++++++++++--------- src/styles/styles.scss | 4 +++ 8 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/@seed/components/ag-grid/autocomplete.component.html b/src/@seed/components/ag-grid/autocomplete.component.html index db0592f0..61e84dd6 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.html +++ b/src/@seed/components/ag-grid/autocomplete.component.html @@ -4,7 +4,6 @@ matInput [formControl]="inputCtrl" [matAutocomplete]="auto" - (keydown)="onKeyDown($event)" /> @for (option of filteredOptions; track $index) { diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts index c842afde..d54a4e4b 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.ts +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -48,12 +48,4 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV this.input.nativeElement.focus() }) } - - onKeyDown(event: KeyboardEvent) { - // if enter, accept the value and stop propagation - const exitKeys = ['Enter'] - if (!exitKeys.includes(event.key)) { - event.stopPropagation() - } - } } 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 a8ac3c5e..5b92c7f4 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 @@ -1,14 +1,19 @@ -
+ +
+
-
Cycle:
+
Cycle:
{{ cycle?.name }}
+
-
Column Profile:
+
Column Profile:
none selected
+
+
@@ -19,6 +24,7 @@
+
@@ -26,9 +32,20 @@
Editable Cell
- - + +
+ @if (errorMessages.length ) { + +
    + @for (error of errorMessages; track $index) { +
  • {{ error }}
  • + } +
+
+ } +
+
+ errorMessages: string[] = [] fileId = this._router.snapshot.params.id as number gridApi: GridApi gridOptions = gridOptions @@ -216,6 +218,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { } validateData() { + this.errorMessages = [] const matchingColumns = this.defaultInventoryType === 'Tax Lot' ? this.matchingTaxLotColumns : this.matchingPropertyColumns const toFields = [] this.gridApi.forEachNode((node: RowNode) => { @@ -223,19 +226,26 @@ export class MapDataComponent implements OnChanges, OnDestroy { toFields.push(node.data.to_field) }) - // no duplicates - if (toFields.length !== new Set(toFields).size) { - this.dataValid = false - return - } // at least one matching column const hasMatchingCol = toFields.some((col) => matchingColumns.includes(col)) if (!hasMatchingCol) { + const matchingColNames = this.columns.filter((c) => c.is_matching_criteria).map((c) => c.display_name).join(', ') + this.errorMessages.push(`At least one of the following Property fields is required: ${matchingColNames}.`) + } + + // all fields must be mapped (no empty fields) + if (!toFields.every((f) => f)) { + this.dataValid = false + this.errorMessages.push('All SEED Headers must be mapped. Empty values are not allowed.') + } + + // no duplicates + if (toFields.length !== new Set(toFields).size) { this.dataValid = false - return + this.errorMessages.push('Duplicate headers found. Each SEED Header must be unique.') } - this.dataValid = true + this.dataValid = this.errorMessages.length === 0 } ngOnDestroy(): void { diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 799af594..1b283ef7 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -99,7 +99,7 @@ export class DatasetComponent implements OnDestroy, OnInit { Data Mapping open_in_new - + Data Pairing open_in_new diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html index 3d51d6d5..07bc471a 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html @@ -1,6 +1,8 @@ -

- Apply Action to Selection ({{ data.viewIds.length }}) -

+
+ +
Apply Action to Selection ({{ data.viewIds.length }})
+
+
    diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts index 3c3dc7ef..d9622e12 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts @@ -2,12 +2,14 @@ import type { OnDestroy } from '@angular/core' import { Component, inject } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' import { Subject } from 'rxjs' @Component({ selector: 'seed-inventory-more-actions-modal', templateUrl: './more-actions-modal.component.html', - imports: [MatButtonModule, MatDialogModule], + imports: [MatButtonModule, MatDialogModule, MatDividerModule, MatIconModule], }) export class MoreActionsModalComponent implements OnDestroy { private _dialogRef = inject(MatDialogRef) @@ -17,27 +19,27 @@ export class MoreActionsModalComponent implements OnDestroy { errorMessage = false actionsColumn1 = [ - { name: 'Add / Remove Groups', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Add / Remove Labels', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Add / Update UBID', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Change ALI', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Compare UBID', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Analysis: Run', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Audit Template: Export', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Audit Template: Update', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Change Access Level', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Data Quality Check', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Decode UBID', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Delete', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Derived Data: Update', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Email', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Export', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'FEMP CTS Reporting Export', action: this.tempAction, disabled: !this.data.viewIds.length }, ] actionsColumn2 = [ - { name: 'Export to AT', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'FEMP CTS Reporting Export', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Geocode', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Groups: Add / Remove', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Labels: Add / Remove', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Merge', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Run Analysis', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Set Update Time to Now', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Update Derived Data', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Update Salesforce', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Update with AT', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Salesforce: Update', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'UBID: Add / Update', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'UBID: Compare', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'UBID: Decode', action: this.tempAction, disabled: !this.data.viewIds.length }, ] tempAction() { diff --git a/src/styles/styles.scss b/src/styles/styles.scss index ea3bedf1..eabaf340 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -213,3 +213,7 @@ border-radius: 25px !important; } } + +.vertical-divider { + @apply border +} From e4b8b2a3a6e53787abaf880de36a0d3270472934 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 7 Jul 2025 20:10:23 +0000 Subject: [PATCH 28/50] data quality results modal --- .../api/data-quality/data-quality.service.ts | 25 +++- .../api/data-quality/data-quality.types.ts | 21 ++++ src/app/modules/data-quality/index.ts | 1 + .../data-quality/results-modal.component.html | 24 ++++ .../data-quality/results-modal.component.ts | 118 ++++++++++++++++++ .../step3/save-mappings.component.ts | 11 +- .../list/grid/actions.component.ts | 2 +- .../modal/more-actions-modal.component.html | 76 ++++++----- .../modal/more-actions-modal.component.ts | 64 ++++++++-- 9 files changed, 297 insertions(+), 45 deletions(-) create mode 100644 src/app/modules/data-quality/index.ts create mode 100644 src/app/modules/data-quality/results-modal.component.html create mode 100644 src/app/modules/data-quality/results-modal.component.ts diff --git a/src/@seed/api/data-quality/data-quality.service.ts b/src/@seed/api/data-quality/data-quality.service.ts index 9202dc71..262dce0e 100644 --- a/src/@seed/api/data-quality/data-quality.service.ts +++ b/src/@seed/api/data-quality/data-quality.service.ts @@ -1,11 +1,11 @@ import { HttpClient, type HttpErrorResponse } from '@angular/common/http' import { inject, Injectable } from '@angular/core' -import { catchError, type Observable, ReplaySubject, switchMap, tap } from 'rxjs' +import { catchError, map, type Observable, ReplaySubject, switchMap, tap } from 'rxjs' import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { Rule } from './data-quality.types' -import { DQCProgressResponse } from '../progress' +import type { DataQualityResults, DataQualityResultsResponse, Rule } from './data-quality.types' +import type { DQCProgressResponse } from '../progress' @Injectable({ providedIn: 'root' }) export class DataQualityService { @@ -91,9 +91,24 @@ export class DataQualityService { ) } - getDataQualityResults(orgId: number, runId: number): Observable { + startDataQualityCheckForOrg(orgId: number, property_view_ids: number[], taxlot_view_ids: number[], goal_id: number): Observable { + const url = `/api/v3/data_quality_checks/${orgId}/start/` + const data = { + property_view_ids, + taxlot_view_ids, + goal_id, + } + return this._httpClient.post(url, data).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching data quality results for organization') + }), + ) + } + + getDataQualityResults(orgId: number, runId: number): Observable { const url = `/api/v3/data_quality_checks/results/?organization_id=${orgId}&run_id=${runId}` - return this._httpClient.get(url).pipe( + return this._httpClient.get(url).pipe( + map(({ data }) => data), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching data quality results') }), diff --git a/src/@seed/api/data-quality/data-quality.types.ts b/src/@seed/api/data-quality/data-quality.types.ts index f1034fb3..5291b421 100644 --- a/src/@seed/api/data-quality/data-quality.types.ts +++ b/src/@seed/api/data-quality/data-quality.types.ts @@ -60,3 +60,24 @@ export type UnitNames = | 'MJ/m²/year' | 'kWh/m²/year' | 'kBtu/m²/year' + +export type DataQualityResultsResponse = { + data: DataQualityResults[]; +} + +export type DataQualityResults = { + [key: string]: unknown; + data_quality_results: DataQualityResult[]; +} + +export type DataQualityResult = { + condition: string; + detailed_message: string; + field: string; + formatted_field: string; + label: string; + message: string; + severity: string; + table_name: string; + value: unknown; +} diff --git a/src/app/modules/data-quality/index.ts b/src/app/modules/data-quality/index.ts new file mode 100644 index 00000000..33a55835 --- /dev/null +++ b/src/app/modules/data-quality/index.ts @@ -0,0 +1 @@ +export * from './results-modal.component' diff --git a/src/app/modules/data-quality/results-modal.component.html b/src/app/modules/data-quality/results-modal.component.html new file mode 100644 index 00000000..9bcc354f --- /dev/null +++ b/src/app/modules/data-quality/results-modal.component.html @@ -0,0 +1,24 @@ +
    + +
    Data Quality Results
    +
    + +
    + @if (rowData.length) { + + + } @else { +
    No warnings or errors
    + } +
    + +
    + +
    \ No newline at end of file diff --git a/src/app/modules/data-quality/results-modal.component.ts b/src/app/modules/data-quality/results-modal.component.ts new file mode 100644 index 00000000..64fd81be --- /dev/null +++ b/src/app/modules/data-quality/results-modal.component.ts @@ -0,0 +1,118 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +import type { DataQualityResults } from '@seed/api/data-quality' +import { DataQualityService } from '@seed/api/data-quality' +import { ConfigService } from '@seed/services' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { Subject, takeUntil, tap } from 'rxjs' + +@Component({ + selector: 'seed-data-quality-results', + templateUrl: './results-modal.component.html', + imports: [ + AgGridAngular, + CommonModule, + MatIconModule, + MatButtonModule, + MatDividerModule, + ], +}) +export class ResultsModalComponent implements OnDestroy, OnInit { + private readonly _unsubscribeAll$ = new Subject() + private _configService = inject(ConfigService) + private _dataQualityService = inject(DataQualityService) + private _dialog = inject(MatDialogRef) + + data = inject(MAT_DIALOG_DATA) as { orgId: number; dqcId: number } + + columnDefs: ColDef[] + gridTheme$ = this._configService.gridTheme$ + rowData: Record[] = [] + results: DataQualityResults[] = [] + + ngOnInit() { + this._dataQualityService.getDataQualityResults(this.data.orgId, this.data.dqcId) + .pipe( + takeUntil(this._unsubscribeAll$), + tap((results) => { + this.results = results + this.setGrid() + }), + ) + .subscribe() + } + + setGrid() { + if (this.results.length) { + this.setColumnDefs() + this.setRowData() + } + } + + setColumnDefs() { + const excludeKeys = ['id', 'data_quality_results'] + const keys = Object.keys(this.results[0]).filter((key) => !excludeKeys.includes(key)) + const matchingColDefs = keys.map((key) => ({ + field: key, + headerName: key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()), + })) + + const styleLookup: Record = { + error: 'bg-red-600 text-white', + warning: 'bg-amber-500 text-white', + } + + const resultDefs = [ + { field: 'table_name', headerName: 'Table' }, + { field: 'formatted_field', headerName: 'Field' }, + { field: 'label', headerName: 'Applied Label' }, + { field: 'severity', hide: true }, + { + field: 'detailed_message', + headerName: 'Error Message', + cellClass: ({ data }: { data: { severity: string } }) => { + return styleLookup[data?.severity] || '' + }, + }, + ] + + this.columnDefs = [...matchingColDefs, ...resultDefs] + } + + setRowData() { + this.rowData = [] + const excludeKeys = ['id', 'data_quality_results'] + const keys = Object.keys(this.results[0]).filter((key) => !excludeKeys.includes(key)) + + for (const result of this.results) { + const matchingData = this.formatMatchingColData(keys, result) + for (const dqc of result.data_quality_results) { + const data = { ...matchingData, ...dqc } + this.rowData.push(data) + } + } + } + + formatMatchingColData(keys: string[], result: Record) { + const data = {} + for (const key of keys) { + data[key] = result[key] + } + return data + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + dismiss() { + this._dialog.close() + } +} diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 65bb5c15..82d4c444 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common' import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatButtonModule } from '@angular/material/button' +import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatProgressBarModule } from '@angular/material/progress-bar' @@ -14,8 +15,9 @@ import { ConfigService } from '@seed/services' import { UploaderService } from '@seed/services/uploader' import { AgGridAngular } from 'ag-grid-angular' import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { ResultsModalComponent } from 'app/modules/data-quality'; import type { InventoryType } from 'app/modules/inventory' -import { Subject, switchMap, take } from 'rxjs' +import { Subject, switchMap, take, tap } from 'rxjs' @Component({ selector: 'seed-save-mappings', @@ -41,6 +43,7 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { private _configService = inject(ConfigService) private _dataQualityService = inject(DataQualityService) + private _dialog = inject(MatDialog) private _uploaderService = inject(UploaderService) private _unsubscribeAll$ = new Subject() columnDefs: ColDef[] = [] @@ -49,6 +52,7 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { gridTheme$ = this._configService.gridTheme$ mappingResults: Record[] = [] dqcComplete = false + dqcId: number inventoryType: InventoryType progressBarObj = this._uploaderService.defaultProgressBarObj @@ -89,6 +93,7 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { progressBarObj: this.progressBarObj, }) }), + tap(({ unique_id }) => { this.dqcId = unique_id }), ) .subscribe() } @@ -139,6 +144,10 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { showDataQualityResults() { console.log('open modal showing dqc results') + this._dialog.open(ResultsModalComponent, { + width: '50rem', + data: { orgId: this.orgId, dqcId: this.dqcId }, + }) } ngOnDestroy(): void { 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 36efb584..51a6185b 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -74,7 +74,7 @@ export class ActionsComponent implements OnDestroy { this._dialog.open(MoreActionsModalComponent, { width: '40rem', autoFocus: false, - data: { viewIds: this.selectedViewIds, orgId: this.orgId }, + data: { viewIds: this.selectedViewIds, orgId: this.orgId, type: this.type }, }) } diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html index 07bc471a..c15e3974 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html @@ -4,39 +4,55 @@
-
-
    - @for (item of actionsColumn1; track $index) { -
  • - -
  • - } -
-
    - @for (item of actionsColumn2; track $index) { -
  • - -
  • - } -
+ +
+
+
    + @for (item of actionsColumn1; track $index) { +
  • + +
  • + } +
+
    + @for (item of actionsColumn2; track $index) { +
  • + +
  • + } +
+
+ + + @if (showProgress) { +
+ + +
+ } +
- +
diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts index d9622e12..42cd9533 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts @@ -1,29 +1,49 @@ import type { OnDestroy } from '@angular/core' import { Component, inject } from '@angular/core' import { MatButtonModule } from '@angular/material/button' -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' -import { Subject } from 'rxjs' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { finalize, Subject, switchMap, take, tap } from 'rxjs' +import { DataQualityService } from '@seed/api/data-quality' +import { ProgressBarComponent } from '@seed/components' +import { UploaderService } from '@seed/services/uploader' +import { ResultsModalComponent } from 'app/modules/data-quality' +import type { InventoryType } from 'app/modules/inventory/inventory.types' @Component({ selector: 'seed-inventory-more-actions-modal', templateUrl: './more-actions-modal.component.html', - imports: [MatButtonModule, MatDialogModule, MatDividerModule, MatIconModule], + imports: [ + MatButtonModule, + MatDialogModule, + MatDividerModule, + MatIconModule, + MatProgressSpinnerModule, + ProgressBarComponent, + ResultsModalComponent, + ], }) export class MoreActionsModalComponent implements OnDestroy { + private _dataQualityService = inject(DataQualityService) + private _dialog = inject(MatDialog) + private _uploaderService = inject(UploaderService) private _dialogRef = inject(MatDialogRef) private readonly _unsubscribeAll$ = new Subject() - data = inject(MAT_DIALOG_DATA) as { viewIds: number[]; orgId: number } + data = inject(MAT_DIALOG_DATA) as { viewIds: number[]; orgId: number; type: InventoryType } errorMessage = false + progressBarObj = this._uploaderService.defaultProgressBarObj + showProgress = false + progressTitle = '' actionsColumn1 = [ { name: 'Analysis: Run', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Audit Template: Export', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Audit Template: Update', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Change Access Level', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Data Quality Check', action: this.tempAction, disabled: !this.data.viewIds.length }, + { name: 'Data Quality Check', action: () => { this.dataQualityCheck() }, disabled: !this.data.viewIds.length }, { name: 'Delete', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Derived Data: Update', action: this.tempAction, disabled: !this.data.viewIds.length }, { name: 'Email', action: this.tempAction, disabled: !this.data.viewIds.length }, @@ -46,11 +66,39 @@ export class MoreActionsModalComponent implements OnDestroy { console.log('temp action') } - close() { - this._dialogRef.close() + dataQualityCheck() { + const [propertyViewIds, taxlotViewIds] = this.data.type === 'properties' ? [this.data.viewIds, []] : [[], this.data.viewIds] + this.progressBarObj.statusMessage = 'Running Data Quality Check...' + this.showProgress = true + this._dataQualityService.startDataQualityCheckForOrg(this.data.orgId, propertyViewIds, taxlotViewIds, null) + .pipe( + take(1), + switchMap(({ progress_key }) => { + return this._uploaderService.checkProgressLoop({ + progressKey: progress_key, + offset: 0, + multiplier: 1, + successFn: () => null, + failureFn: () => null, + progressBarObj: this.progressBarObj, + }) + }), + tap(({ unique_id }) => { this.openDataQualityResultsModal(unique_id) }), + finalize(() => { this.showProgress = false }), + ) + .subscribe() + } + + openDataQualityResultsModal(dqcId: number) { + this._dialog.open(ResultsModalComponent, { + width: '50rem', + data: { orgId: this.data.orgId, dqcId }, + }) + + this.close() } - dismiss() { + close() { this._dialogRef.close() } From a48db62040f105ff6e32d1c093df78f4cdfe07f6 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 8 Jul 2025 18:48:09 +0000 Subject: [PATCH 29/50] fetch and apply profile --- .../column_mapping_profile.service.ts | 9 ++++- .../column_mapping_profile.types.ts | 2 + src/@seed/api/mapping/mapping.service.ts | 3 -- .../data-mappings/data-mapping.component.html | 1 + .../data-mappings/data-mapping.component.ts | 18 +++++++-- .../step1/map-data.component.html | 34 ++++++++++++---- .../data-mappings/step1/map-data.component.ts | 39 ++++++++++++++++--- .../data-mappings/step4/results.component.ts | 1 - .../list/grid/actions.component.ts | 8 +++- .../modal/more-actions-modal.component.ts | 6 +-- 10 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts b/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts index 0d08df4d..44c66c6c 100644 --- a/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts +++ b/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts @@ -10,6 +10,7 @@ import type { ColumnMappingProfile, ColumnMappingProfileDeleteResponse, ColumnMappingProfilesRequest, + ColumnMappingProfileType, ColumnMappingProfileUpdateResponse, ColumnMappingSuggestionResponse, } from './column_mapping_profile.types' @@ -30,9 +31,13 @@ export class ColumnMappingProfileService { }) } - getProfiles(org_id: number): Observable { + getProfiles(org_id: number, columnMappingProfileTypes: ColumnMappingProfileType[] = []): Observable { const url = `/api/v3/column_mapping_profiles/filter/?organization_id=${org_id}` - return this._httpClient.post(url, {}).pipe( + const data: Record = {} + if (columnMappingProfileTypes.length) { + data.profile_type = columnMappingProfileTypes + } + return this._httpClient.post(url, data).pipe( map((response) => { this._profiles.next(response.data) return response.data diff --git a/src/@seed/api/column_mapping_profile/column_mapping_profile.types.ts b/src/@seed/api/column_mapping_profile/column_mapping_profile.types.ts index f2fd5a0b..6e28494d 100644 --- a/src/@seed/api/column_mapping_profile/column_mapping_profile.types.ts +++ b/src/@seed/api/column_mapping_profile/column_mapping_profile.types.ts @@ -33,3 +33,5 @@ export type ColumnMappingSuggestionResponse = { status: string; data: Record; } + +export type ColumnMappingProfileType = 'Normal' | 'BuildingSync Default' | 'BuildingSync Custom' diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index 5e09ff0c..c01edf42 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -94,9 +94,6 @@ export class MappingService { // returns ProgressResponse if already matched return this._httpClient.post(url, {}) .pipe( - tap((response) => { - console.log('Match merge started:', response) - }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error starting match merge') }), diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 0016eab4..8904f597 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -31,6 +31,7 @@ () + private _columnMappingProfileService = inject(ColumnMappingProfileService) private _columnService = inject(ColumnService) private _cycleService = inject(CycleService) private _datasetService = inject(DatasetService) @@ -72,6 +75,8 @@ export class DataMappingComponent implements OnDestroy, OnInit { private _uploaderService = inject(UploaderService) private _userService = inject(UserService) columns: Column[] + columnMappingProfiles: ColumnMappingProfile[] = [] + columnMappingProfileTypes: ColumnMappingProfileType[] columnNames: string[] completed = { 1: false, 2: false, 3: false, 4: false } currentProfile: Profile @@ -114,7 +119,10 @@ export class DataMappingComponent implements OnDestroy, OnInit { return this._datasetService.getImportFile(this.orgId, this.fileId) .pipe( take(1), - tap((importFile) => { this.importFile = importFile }), + tap((importFile) => { + this.importFile = importFile + this.columnMappingProfileTypes = importFile.source_type === 'BuildingSync Raw' ? ['BuildingSync Default', 'BuildingSync Custom'] : ['Normal'] + }), catchError(() => { return of(null) }), @@ -123,6 +131,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { getMappingData() { return forkJoin([ + this._columnMappingProfileService.getProfiles(this.orgId, this.columnMappingProfileTypes), this._cycleService.getCycle(this.orgId, this.importFile.cycle), this._mappingService.firstFiveRows(this.orgId, this.fileId), this._mappingService.mappingSuggestions(this.orgId, this.fileId), @@ -130,7 +139,8 @@ export class DataMappingComponent implements OnDestroy, OnInit { ]) .pipe( take(1), - tap(([cycle, firstFiveRows, mappingSuggestions, rawColumnNames]) => { + tap(([columnMappingProfiles, cycle, firstFiveRows, mappingSuggestions, rawColumnNames]) => { + this.columnMappingProfiles = columnMappingProfiles this.cycle = cycle this.firstFiveRows = firstFiveRows this.mappingSuggestions = mappingSuggestions 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 5b92c7f4..6b7765bc 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 @@ -1,20 +1,40 @@ - -
-
+
+
Cycle:
{{ cycle?.name }}
-
+ +
Column Profile:
-
none selected
+ + + @for (profile of columnMappingProfiles; track profile.id) { + {{ profile.name }} + } + + + + + + +
-
+
- +
diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index b0ec7cf2..8cc7c714 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -1,27 +1,29 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import { CommonModule } from '@angular/common' import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' -import { Component, EventEmitter, inject, Input, Output } from '@angular/core' +import { Component, EventEmitter, inject, input, Input, Output } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MatOptionModule } from '@angular/material/core' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatSelectModule } from '@angular/material/select' import { MatSidenavModule } from '@angular/material/sidenav' import { MatStepperModule } from '@angular/material/stepper' import { ActivatedRoute } from '@angular/router' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' +import { Subject } from 'rxjs' import { type Column } from '@seed/api/column' +import type { ColumnMappingProfile } from '@seed/api/column_mapping_profile' import type { Cycle } from '@seed/api/cycle' import type { DataMappingRow, ImportFile } from '@seed/api/dataset' import type { MappingSuggestionsResponse } from '@seed/api/mapping' import { AlertComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' import type { ProgressBarObj } from '@seed/services/uploader' -import { AgGridAngular } from 'ag-grid-angular' -import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' import type { InventoryDisplayType, Profile } from 'app/modules/inventory' -import { Subject } from 'rxjs' import { HelpComponent } from '../help.component' import { buildColumnDefs, gridOptions } from './column-defs' import { dataTypeMap, displayToDataTypeMap } from './constants' @@ -38,6 +40,7 @@ import { dataTypeMap, displayToDataTypeMap } from './constants' MatButtonToggleModule, MatDividerModule, MatIconModule, + MatOptionModule, MatSidenavModule, MatSelectModule, MatStepperModule, @@ -49,6 +52,7 @@ import { dataTypeMap, displayToDataTypeMap } from './constants' export class MapDataComponent implements OnChanges, OnDestroy { @Input() orgId: number @Input() importFile: ImportFile + @Input() columnMappingProfiles: ColumnMappingProfile[] @Input() cycle: Cycle @Input() firstFiveRows: Record[] @Input() mappingSuggestions: MappingSuggestionsResponse @@ -60,6 +64,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { private readonly _unsubscribeAll$ = new Subject() private _configService = inject(ConfigService) private _router = inject(ActivatedRoute) + profile: ColumnMappingProfile columns: Column[] columnNames: string[] columnMap: Record @@ -196,6 +201,29 @@ export class MapDataComponent implements OnChanges, OnDestroy { }) } + applyProfile() { + const mappingsMap = Object.fromEntries(this.profile.mappings.map((m) => [m.from_field, m])) + const columnNameMap = Object.fromEntries(this.columns.map((c) => [c.column_name, c.display_name])) + this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { + const mapping = mappingsMap[node.data.from_field] + if (!mapping) return // skip if no mapping found + + const displayField = columnNameMap[mapping.to_field] ?? '' + node.setDataValue('to_field_display_name', displayField) + node.setDataValue('to_field', mapping.to_field) + node.setDataValue('from_units', mapping.from_units) + node.setDataValue('to_table_name', mapping.to_table_name) + }) + } + + saveProfile() { + console.log('save profile') + } + + createProfile() { + console.log('create profile') + } + // Format data for backend consumption mapData() { if (!this.dataValid) return @@ -220,7 +248,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { validateData() { this.errorMessages = [] const matchingColumns = this.defaultInventoryType === 'Tax Lot' ? this.matchingTaxLotColumns : this.matchingPropertyColumns - const toFields = [] + let toFields = [] this.gridApi.forEachNode((node: RowNode) => { if (node.data.omit) return // skip omitted rows toFields.push(node.data.to_field) @@ -240,6 +268,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { } // no duplicates + toFields = toFields.filter((f) => f) if (toFields.length !== new Set(toFields).size) { this.dataValid = false this.errorMessages.push('Duplicate headers found. Each SEED Header must be unique.') diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.ts b/src/app/modules/datasets/data-mappings/step4/results.component.ts index 2724e94e..b3671c86 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/results.component.ts @@ -51,7 +51,6 @@ export class ResultsComponent implements OnChanges, OnDestroy { hasTaxlotData = false ngOnChanges(changes: SimpleChanges): void { - console.log('changes', changes) if (changes.inProgress.currentValue === false) { this.getMatchingResults() } 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 51a6185b..9a6855dd 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -71,11 +71,17 @@ export class ActionsComponent implements OnDestroy { } openMoreActionsModal() { - this._dialog.open(MoreActionsModalComponent, { + 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() } onAction(action: () => void, select: MatSelect) { diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts index 42cd9533..e9606d07 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts @@ -95,11 +95,11 @@ export class MoreActionsModalComponent implements OnDestroy { data: { orgId: this.data.orgId, dqcId }, }) - this.close() + this.close(true) } - close() { - this._dialogRef.close() + close(refresh = false) { + this._dialogRef.close(refresh) } ngOnDestroy(): void { From aba8394dfb1686ad5f59593bb02f812bc8dcd9d1 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 8 Jul 2025 20:15:20 +0000 Subject: [PATCH 30/50] save profi;e --- .../column_mapping_profile.service.ts | 8 +++-- .../ag-grid/autocomplete.component.ts | 8 +++-- .../step1/map-data.component.html | 13 ++++--- .../data-mappings/step1/map-data.component.ts | 34 ++++++++++++++++--- .../columns/mappings/mappings.component.ts | 2 +- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts b/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts index 44c66c6c..4e59d94e 100644 --- a/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts +++ b/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts @@ -2,8 +2,9 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { catchError, map, ReplaySubject } from 'rxjs' +import { catchError, map, ReplaySubject, tap } from 'rxjs' import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import { UserService } from '../user' import type { ColumnMapping, @@ -21,6 +22,7 @@ export class ColumnMappingProfileService { private _userService = inject(UserService) private _profiles = new ReplaySubject(1) private _errorService = inject(ErrorService) + private _snackBar = inject(SnackBarService) profiles$ = this._profiles.asObservable() @@ -63,10 +65,11 @@ export class ColumnMappingProfileService { update(org_id: number, profile: ColumnMappingProfile): Observable { const url = `/api/v3/column_mapping_profiles/${profile.id}/?organization_id=${org_id}` - return this._httpClient.put(url, { name: profile.name }).pipe( + return this._httpClient.put(url, profile).pipe( map((response) => { return response.data }), + tap(() => { this._snackBar.success('Profile updated successfully') }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating profile') }), @@ -88,6 +91,7 @@ export class ColumnMappingProfileService { create(org_id: number, profile: ColumnMappingProfile): Observable { const url = `/api/v3/column_mapping_profiles/?organization_id=${org_id}` return this._httpClient.post(url, { ...profile }).pipe( + tap(() => { this._snackBar.success('Profile created successfully') }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error creating profile') }), diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts index d54a4e4b..1c31c081 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.ts +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -23,12 +23,12 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV inputCtrl = new FormControl('') filteredOptions: string[] = [] - params!: unknown + params!: ICellEditorParams & { values?: string[] } options: string[] = [] - agInit(params: ICellEditorParams): void { + agInit(params: ICellEditorParams & { values?: string[] }): void { this.params = params - this.options = ((params as unknown) as { values: string[] }).values || [] + this.options = params.values || [] this.inputCtrl.setValue(params.value as string) this.filteredOptions = [...this.options] this.inputCtrl.valueChanges.subscribe((value) => { @@ -36,6 +36,8 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV this.filteredOptions = this.options.filter((option) => { return option.toLowerCase().startsWith(value.toLowerCase()) }) + // update after each keystroke + this.params.node.setDataValue(this.params.column.getId(), value) }) } diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.html b/src/app/modules/datasets/data-mappings/step1/map-data.component.html index 6b7765bc..fd535ec0 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 @@ -7,7 +7,7 @@
-
Column Profile:
+
Column Profile:
{{ profile.name }} } - + + + + + diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index 8cc7c714..6856f732 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import { CommonModule } from '@angular/common' import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' -import { Component, EventEmitter, inject, input, Input, Output } from '@angular/core' +import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { MatButtonToggleModule } from '@angular/material/button-toggle' @@ -11,12 +11,13 @@ import { MatIconModule } from '@angular/material/icon' import { MatSelectModule } from '@angular/material/select' import { MatSidenavModule } from '@angular/material/sidenav' import { MatStepperModule } from '@angular/material/stepper' +import { MatTooltipModule } from '@angular/material/tooltip' import { ActivatedRoute } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' import { Subject } from 'rxjs' import { type Column } from '@seed/api/column' -import type { ColumnMappingProfile } from '@seed/api/column_mapping_profile' +import { type ColumnMapping, type ColumnMappingProfile, ColumnMappingProfileService } from '@seed/api/column_mapping_profile' import type { Cycle } from '@seed/api/cycle' import type { DataMappingRow, ImportFile } from '@seed/api/dataset' import type { MappingSuggestionsResponse } from '@seed/api/mapping' @@ -44,6 +45,7 @@ import { dataTypeMap, displayToDataTypeMap } from './constants' MatSidenavModule, MatSelectModule, MatStepperModule, + MatTooltipModule, PageComponent, ReactiveFormsModule, FormsModule, @@ -63,6 +65,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { private readonly _unsubscribeAll$ = new Subject() private _configService = inject(ConfigService) + private _columnMappingProfileService = inject(ColumnMappingProfileService) private _router = inject(ActivatedRoute) profile: ColumnMappingProfile columns: Column[] @@ -106,6 +109,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { to_field_display_name: null, to_table_name: this.defaultInventoryType, to_data_type: null, + to_field: null, from_units: null, } this.setColumnDefs() @@ -170,7 +174,6 @@ export class MapDataComponent implements OnChanges, OnDestroy { to_field, from_units: dataTypeConfig.units, }) - this.refreshNode(node) this.validateData() } @@ -202,6 +205,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { } applyProfile() { + const toTableMap = { TaxLotState: 'Tax Lot', PropertyState: 'Property' } const mappingsMap = Object.fromEntries(this.profile.mappings.map((m) => [m.from_field, m])) const columnNameMap = Object.fromEntries(this.columns.map((c) => [c.column_name, c.display_name])) this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { @@ -212,12 +216,32 @@ export class MapDataComponent implements OnChanges, OnDestroy { node.setDataValue('to_field_display_name', displayField) node.setDataValue('to_field', mapping.to_field) node.setDataValue('from_units', mapping.from_units) - node.setDataValue('to_table_name', mapping.to_table_name) + node.setDataValue('to_table_name', toTableMap[mapping.to_table_name]) }) } saveProfile() { - console.log('save profile') + // overwrite the existing profile + const mappings: ColumnMapping[] = [] + this.gridApi.forEachNode((node) => { + const mapping = this.formatRowToMapping(node.data) + if (mapping) mappings.push(mapping) + }) + this.profile.mappings = mappings + this._columnMappingProfileService.update(this.orgId, this.profile).subscribe() + } + + formatRowToMapping(row: Record): ColumnMapping { + if (!row.to_field) return null + const to_table_name = row.to_table_name === 'Tax Lot' ? 'TaxLotState' : 'PropertyState' + const mapping: ColumnMapping = { + from_field: row.from_field as string, + from_units: row.from_units as string, + to_field: row.to_field as string, + to_table_name, + } + if (row.omit) mapping.is_omitted = true + return mapping } createProfile() { diff --git a/src/app/modules/organizations/columns/mappings/mappings.component.ts b/src/app/modules/organizations/columns/mappings/mappings.component.ts index b3eedb1c..dd50c59a 100644 --- a/src/app/modules/organizations/columns/mappings/mappings.component.ts +++ b/src/app/modules/organizations/columns/mappings/mappings.component.ts @@ -107,7 +107,7 @@ export class MappingsComponent implements ComponentCanDeactivate, OnDestroy, OnI field: 'to_table_name', editable: false, valueFormatter: (params: ValueFormatterParams) => { - return (params.value as string).slice(0, -5) + return (params.value as string)?.slice(0, -5) }, }, { From 8ec5e37020aa4c58fd02a86028e093661dcd80eb Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 13:57:02 +0000 Subject: [PATCH 31/50] profiles fxnal --- .../column_mapping_profile.service.ts | 28 +++++----- .../data-mappings/data-mapping.component.ts | 15 ++++- .../step1/map-data.component.html | 6 +- .../data-mappings/step1/map-data.component.ts | 29 ++++++++-- .../step1/modal/create-profile.component.html | 30 ++++++++++ .../step1/modal/create-profile.component.ts | 55 +++++++++++++++++++ 6 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html create mode 100644 src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts diff --git a/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts b/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts index 4e59d94e..7d2f2ae1 100644 --- a/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts +++ b/src/@seed/api/column_mapping_profile/column_mapping_profile.service.ts @@ -33,8 +33,8 @@ export class ColumnMappingProfileService { }) } - getProfiles(org_id: number, columnMappingProfileTypes: ColumnMappingProfileType[] = []): Observable { - const url = `/api/v3/column_mapping_profiles/filter/?organization_id=${org_id}` + getProfiles(orgId: number, columnMappingProfileTypes: ColumnMappingProfileType[] = []): Observable { + const url = `/api/v3/column_mapping_profiles/filter/?organization_id=${orgId}` const data: Record = {} if (columnMappingProfileTypes.length) { data.profile_type = columnMappingProfileTypes @@ -51,8 +51,8 @@ export class ColumnMappingProfileService { ) } - updateMappings(org_id: number, profile_id: number, mappings: ColumnMapping[]): Observable { - const url = `/api/v3/column_mapping_profiles/${profile_id}/?organization_id=${org_id}` + updateMappings(orgId: number, profile_id: number, mappings: ColumnMapping[]): Observable { + const url = `/api/v3/column_mapping_profiles/${profile_id}/?organization_id=${orgId}` return this._httpClient.put(url, { mappings }).pipe( map((response) => { return response.data @@ -63,8 +63,8 @@ export class ColumnMappingProfileService { ) } - update(org_id: number, profile: ColumnMappingProfile): Observable { - const url = `/api/v3/column_mapping_profiles/${profile.id}/?organization_id=${org_id}` + update(orgId: number, profile: ColumnMappingProfile): Observable { + const url = `/api/v3/column_mapping_profiles/${profile.id}/?organization_id=${orgId}` return this._httpClient.put(url, profile).pipe( map((response) => { return response.data @@ -76,8 +76,8 @@ export class ColumnMappingProfileService { ) } - delete(org_id: number, profile_id: number): Observable { - const url = `/api/v3/column_mapping_profiles/${profile_id}/?organization_id=${org_id}` + delete(orgId: number, profile_id: number): Observable { + const url = `/api/v3/column_mapping_profiles/${profile_id}/?organization_id=${orgId}` return this._httpClient.delete(url).pipe( map((response) => { return response @@ -88,8 +88,8 @@ export class ColumnMappingProfileService { ) } - create(org_id: number, profile: ColumnMappingProfile): Observable { - const url = `/api/v3/column_mapping_profiles/?organization_id=${org_id}` + create(orgId: number, profile: ColumnMappingProfile): Observable { + const url = `/api/v3/column_mapping_profiles/?organization_id=${orgId}` return this._httpClient.post(url, { ...profile }).pipe( tap(() => { this._snackBar.success('Profile created successfully') }), catchError((error: HttpErrorResponse) => { @@ -98,8 +98,8 @@ export class ColumnMappingProfileService { ) } - export(org_id: number, profile_id: number) { - const url = `/api/v3/column_mapping_profiles/${profile_id}/csv/?organization_id=${org_id}` + export(orgId: number, profile_id: number) { + const url = `/api/v3/column_mapping_profiles/${profile_id}/csv/?organization_id=${orgId}` return this._httpClient.get(url, { responseType: 'text' }).pipe( map((response) => { return new Blob([response], { type: 'text/csv;charset: utf-8' }) @@ -110,8 +110,8 @@ export class ColumnMappingProfileService { ) } - suggestions(org_id: number, headers: string[]) { - const url = `/api/v3/column_mapping_profiles/suggestions/?organization_id=${org_id}` + suggestions(orgId: number, headers: string[]) { + const url = `/api/v3/column_mapping_profiles/suggestions/?organization_id=${orgId}` return this._httpClient.post(url, { headers }).pipe( map((response) => { return response.data diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index aaa8eb5e..832539bc 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -109,6 +109,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { }), switchMap(() => this.getImportFile()), filter(Boolean), + tap(() => { this.getProfiles() }), switchMap(() => this.getMappingData()), switchMap(() => this.getColumns()), ) @@ -131,7 +132,6 @@ export class DataMappingComponent implements OnDestroy, OnInit { getMappingData() { return forkJoin([ - this._columnMappingProfileService.getProfiles(this.orgId, this.columnMappingProfileTypes), this._cycleService.getCycle(this.orgId, this.importFile.cycle), this._mappingService.firstFiveRows(this.orgId, this.fileId), this._mappingService.mappingSuggestions(this.orgId, this.fileId), @@ -139,8 +139,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { ]) .pipe( take(1), - tap(([columnMappingProfiles, cycle, firstFiveRows, mappingSuggestions, rawColumnNames]) => { - this.columnMappingProfiles = columnMappingProfiles + tap(([cycle, firstFiveRows, mappingSuggestions, rawColumnNames]) => { this.cycle = cycle this.firstFiveRows = firstFiveRows this.mappingSuggestions = mappingSuggestions @@ -172,6 +171,16 @@ export class DataMappingComponent implements OnDestroy, OnInit { ) } + getProfiles() { + this._columnMappingProfileService.getProfiles(this.orgId, this.columnMappingProfileTypes) + .pipe( + switchMap(() => this._columnMappingProfileService.profiles$), + takeUntil(this._unsubscribeAll$), + tap((profiles) => { this.columnMappingProfiles = profiles }), + ) + .subscribe() + } + onCompleted(step: number) { this.completed[step] = true this.stepper.next() 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 fd535ec0..9a80f341 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 @@ -19,13 +19,13 @@ } - + - + - +
diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index 6856f732..d07b5607 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -6,6 +6,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { MatButtonToggleModule } from '@angular/material/button-toggle' import { MatOptionModule } from '@angular/material/core' +import { MatDialog } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatSelectModule } from '@angular/material/select' @@ -15,8 +16,9 @@ import { MatTooltipModule } from '@angular/material/tooltip' import { ActivatedRoute } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' -import { Subject } from 'rxjs' +import { Subject, switchMap, take } from 'rxjs' import { type Column } from '@seed/api/column' +import type { ColumnMappingProfileType } from '@seed/api/column_mapping_profile' import { type ColumnMapping, type ColumnMappingProfile, ColumnMappingProfileService } from '@seed/api/column_mapping_profile' import type { Cycle } from '@seed/api/cycle' import type { DataMappingRow, ImportFile } from '@seed/api/dataset' @@ -24,10 +26,11 @@ import type { MappingSuggestionsResponse } from '@seed/api/mapping' import { AlertComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' import type { ProgressBarObj } from '@seed/services/uploader' -import type { InventoryDisplayType, Profile } from 'app/modules/inventory' +import type { InventoryDisplayType } from 'app/modules/inventory' import { HelpComponent } from '../help.component' import { buildColumnDefs, gridOptions } from './column-defs' import { dataTypeMap, displayToDataTypeMap } from './constants' +import { CreateProfileComponent } from './modal/create-profile.component' @Component({ selector: 'seed-map-data', @@ -66,13 +69,13 @@ export class MapDataComponent implements OnChanges, OnDestroy { private readonly _unsubscribeAll$ = new Subject() private _configService = inject(ConfigService) private _columnMappingProfileService = inject(ColumnMappingProfileService) + private _dialog = inject(MatDialog) private _router = inject(ActivatedRoute) profile: ColumnMappingProfile columns: Column[] columnNames: string[] columnMap: Record columnDefs: ColDef[] - currentProfile: Profile dataValid = false defaultInventoryType: InventoryDisplayType = 'Property' defaultRow: Record @@ -212,7 +215,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { const mapping = mappingsMap[node.data.from_field] if (!mapping) return // skip if no mapping found - const displayField = columnNameMap[mapping.to_field] ?? '' + const displayField = columnNameMap[mapping.to_field] ?? mapping.to_field node.setDataValue('to_field_display_name', displayField) node.setDataValue('to_field', mapping.to_field) node.setDataValue('from_units', mapping.from_units) @@ -246,6 +249,24 @@ export class MapDataComponent implements OnChanges, OnDestroy { createProfile() { console.log('create profile') + const profileType: ColumnMappingProfileType = this.importFile.source_type === 'BuildingSync' ? 'BuildingSync Custom' : 'Normal' + const profileTypes: ColumnMappingProfileType[] = profileType === 'BuildingSync Custom' ? ['BuildingSync Default', 'BuildingSync Custom'] : ['Normal'] + const dialogRef = this._dialog.open(CreateProfileComponent, { + width: '40rem', + data: { + existingNames: this.columnMappingProfiles.map((p) => p.name), + orgId: this.orgId, + profileType, + }, + }) + + dialogRef + .afterClosed() + .pipe( + take(1), + switchMap(() => this._columnMappingProfileService.getProfiles(this.orgId, profileTypes)), + ) + .subscribe() } // Format data for backend consumption diff --git a/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html new file mode 100644 index 00000000..3e8e3600 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html @@ -0,0 +1,30 @@ +
+ +
Create Column Mapping Profile
+
+ + + +
+ + + + Name + + @if (form.controls.name?.hasError('valueExists')) { + This name already exists. + } + + + +
+ + +
+ + + + + + +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts new file mode 100644 index 00000000..9aec2747 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts @@ -0,0 +1,55 @@ +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import type { ColumnMappingProfile, ColumnMappingProfileType } from '@seed/api/column_mapping_profile' +import { ColumnMappingProfileService } from '@seed/api/column_mapping_profile' +import { SEEDValidators } from '@seed/validators' + +@Component({ + selector: 'seed-data-mapping-create-profile', + templateUrl: './create-profile.component.html', + imports: [ + FormsModule, + MatIconModule, + MatButtonModule, + MatDividerModule, + MatFormFieldModule, + MatInputModule, + MatDialogModule, + ReactiveFormsModule, + ], +}) +export class CreateProfileComponent { + private _columnMappingProfileService = inject(ColumnMappingProfileService) + private _dialogRef = inject(MatDialogRef) + + profileName = '' + profile: ColumnMappingProfile + + data = inject(MAT_DIALOG_DATA) as { orgId: number; profileType: ColumnMappingProfileType; existingNames: string[] } + form = new FormGroup({ + name: new FormControl('', [Validators.required, SEEDValidators.uniqueValue(this.data.existingNames)]), + }) + + onSubmit() { + this.profile = { + name: this.form.value.name, + profile_type: this.data.profileType, + mappings: [], + } as ColumnMappingProfile + + this._columnMappingProfileService.create(this.data.orgId, this.profile) + .subscribe(() => { + this.close() + }) + } + + close() { + this._dialogRef.close() + } +} From f191f38e7722110968106835aa4825db4069ae82 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 15:04:24 +0000 Subject: [PATCH 32/50] hook up delete taxlots from inv list --- src/@seed/api/inventory/inventory.service.ts | 14 ++++++++++++++ .../inventory-list/list/grid/actions.component.ts | 13 +++++++++---- .../inventory-list/list/inventory.component.ts | 4 +++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index 01d3d42c..0ebb4d69 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -162,6 +162,20 @@ export class InventoryService { ) } + deleteTaxlotStates({ orgId, viewIds }: DeleteParams): Observable { + const url = '/api/v3/taxlots/batch_delete/' + const data = { taxlot_view_ids: viewIds } + const options = { params: { organization_id: orgId }, body: data } + return this._httpClient.delete(url, options).pipe( + tap(() => { + this._snackBar.success('Tax lot states deleted') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting tax lot states') + }), + ) + } + /* * Get PropertyView or TaxLotView */ 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 9a6855dd..cd4bbbf8 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -54,7 +54,7 @@ export class ActionsComponent implements OnDestroy { }, disabled: !this.inventory, }, - { name: 'Delete', action: this.deletePropertyStates, disabled: !this.selectedViewIds.length }, + { name: 'Delete', action: this.deleteStates, disabled: !this.selectedViewIds.length }, { name: 'Merge', action: this.tempAction, disabled: !this.selectedViewIds.length }, { name: 'More...', @@ -110,10 +110,11 @@ export class ActionsComponent implements OnDestroy { this.gridApi.deselectAll() } - deletePropertyStates = () => { + deleteStates = () => { + const displayType = this.type === 'taxlots' ? 'Tax Lot' : 'Property' const dialogRef = this._dialog.open(DeleteModalComponent, { width: '40rem', - data: { model: `${this.selectedViewIds.length} Property States`, instance: '' }, + data: { model: `${this.selectedViewIds.length} ${displayType} States`, instance: '' }, }) dialogRef @@ -121,7 +122,11 @@ export class ActionsComponent implements OnDestroy { .pipe( takeUntil(this._unsubscribeAll$), filter(Boolean), - switchMap(() => this._inventoryService.deletePropertyStates({ orgId: this.orgId, viewIds: this.selectedViewIds })), + switchMap(() => { + return this.type === 'taxlots' + ? this._inventoryService.deleteTaxlotStates({ orgId: this.orgId, viewIds: this.selectedViewIds }) + : this._inventoryService.deletePropertyStates({ orgId: this.orgId, viewIds: this.selectedViewIds }) + }), tap(() => { this.refreshInventory.emit() }), diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index f84648ce..5cedc297 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -268,7 +268,9 @@ export class InventoryComponent implements OnDestroy, OnInit { } onSelectionChanged() { - this.selectedViewIds = 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) } onSelectAll(selectedViewIds: number[]) { From 3c2222090903763f4b6d52dac3e4362e7bbd5b36 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 15:20:01 +0000 Subject: [PATCH 33/50] upload props and taxlots related --- .../data-mappings/step1/column-defs.ts | 18 ++++++-- .../data-mappings/step1/map-data.component.ts | 45 +++++++++++++------ .../step4/results.component.html | 36 ++++++++------- .../data-mappings/step4/results.component.ts | 10 ----- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/app/modules/datasets/data-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts index e1f01159..a6c07089 100644 --- a/src/app/modules/datasets/data-mappings/step1/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -39,8 +39,18 @@ const dropdownRenderer = (params: ICellRendererParams) => { const canEditClass = 'bg-primary bg-opacity-25 rounded' +const getColumnOptions = (params: ICellRendererParams, propertyColumnNames: string[], taxlotColumnNames: string[]) => { + const data = params.data as { to_table_name: 'Property' | 'Tax Lot' } + const to_table_name = data.to_table_name + if (to_table_name === 'Tax Lot') { + return taxlotColumnNames + } + return propertyColumnNames +} + export const buildColumnDefs = ( - columnNames: string[], + propertyColumnNames: string[], + taxlotColumnNames: string[], uploadedFilename: string, seedHeaderChange: (event: CellValueChangedEvent) => void, dataTypeChange: (event: CellValueChangedEvent) => void, @@ -77,9 +87,9 @@ export const buildColumnDefs = ( field: 'to_field_display_name', headerName: 'SEED Header', cellEditor: AutocompleteCellComponent, - cellEditorParams: { - values: columnNames, - }, + cellEditorParams: (params) => { + return { values: getColumnOptions(params, propertyColumnNames, taxlotColumnNames)} + } , headerComponent: EditHeaderComponent, headerComponentParams: { name: 'SEED Header', diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index d07b5607..e5ec3149 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -71,10 +71,6 @@ export class MapDataComponent implements OnChanges, OnDestroy { private _columnMappingProfileService = inject(ColumnMappingProfileService) private _dialog = inject(MatDialog) private _router = inject(ActivatedRoute) - profile: ColumnMappingProfile - columns: Column[] - columnNames: string[] - columnMap: Record columnDefs: ColDef[] dataValid = false defaultInventoryType: InventoryDisplayType = 'Property' @@ -85,7 +81,14 @@ export class MapDataComponent implements OnChanges, OnDestroy { gridOptions = gridOptions gridTheme$ = this._configService.gridTheme$ mappedData: { mappings: DataMappingRow[] } = { mappings: [] } + profile: ColumnMappingProfile + propertyColumns: Column[] = [] + propertyColumnMap: Record + propertyColumnNames: string[] rowData: Record[] = [] + taxlotColumns: Column[] = [] + taxlotColumnMap: Record + taxlotColumnNames: string[] progressBarObj: ProgressBarObj = { message: [], @@ -121,7 +124,8 @@ export class MapDataComponent implements OnChanges, OnDestroy { setColumnDefs() { this.columnDefs = buildColumnDefs( - this.columnNames, + this.propertyColumnNames, + this.taxlotColumnNames, this.importFile.uploaded_filename, this.seedHeaderChange.bind(this), this.dataTypeChange.bind(this), @@ -152,20 +156,23 @@ export class MapDataComponent implements OnChanges, OnDestroy { setAllInventoryType(value: InventoryDisplayType) { this.defaultInventoryType = value - this.gridApi.forEachNode((node) => node.setDataValue('to_table_display_name', value)) + this.gridApi.forEachNode((node) => node.setDataValue('to_table_name', value)) this.setColumns() } setColumns() { - this.columns = this.defaultInventoryType === 'Tax Lot' ? this.mappingSuggestions?.taxlot_columns : this.mappingSuggestions?.property_columns - this.columnNames = this.columns.map((c) => c.display_name) - this.columnMap = Object.fromEntries(this.columns.map((c) => [c.display_name, c])) + this.propertyColumns = this.mappingSuggestions?.property_columns ?? [] + this.propertyColumnNames = this.mappingSuggestions?.property_columns.map((c) => c.display_name) ?? [] + this.propertyColumnMap = Object.fromEntries(this.propertyColumns.map((c) => [c.display_name, c])) + this.taxlotColumns = this.mappingSuggestions?.taxlot_columns ?? [] + this.taxlotColumnNames = this.mappingSuggestions?.taxlot_columns.map((c) => c.display_name) ?? [] + this.taxlotColumnMap = Object.fromEntries(this.taxlotColumns.map((c) => [c.display_name, c])) } seedHeaderChange = (params: CellValueChangedEvent): void => { const node = params.node as RowNode const newValue = params.newValue as string - const column = this.columnMap[newValue] ?? null + const column = this.getColumnMap(node.data)[newValue] ?? null const dataTypeConfig = dataTypeMap[column?.data_type] ?? { display: 'None', units: null } const to_field = column?.column_name ?? newValue @@ -181,6 +188,16 @@ export class MapDataComponent implements OnChanges, OnDestroy { this.validateData() } + getColumnMap(nodeData: { to_table_name: InventoryDisplayType }) { + if (nodeData.to_table_name === 'Tax Lot') return this.taxlotColumnMap + if (nodeData.to_table_name === 'Property') return this.propertyColumnMap + return this.defaultInventoryType === 'Tax Lot' ? this.taxlotColumnMap : this.propertyColumnMap + } + + getColumns() { + return this.defaultInventoryType === 'Tax Lot' ? this.taxlotColumns : this.propertyColumns + } + dataTypeChange = (params: CellValueChangedEvent): void => { const node = params.node as RowNode node.setDataValue('from_units', null) @@ -195,8 +212,8 @@ export class MapDataComponent implements OnChanges, OnDestroy { } copyHeadersToSeed() { - const { property_columns, taxlot_columns, suggested_column_mappings } = this.mappingSuggestions - const columns = this.defaultInventoryType === 'Tax Lot' ? taxlot_columns : property_columns + const { suggested_column_mappings } = this.mappingSuggestions + const columns = this.getColumns() const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { @@ -210,7 +227,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { applyProfile() { const toTableMap = { TaxLotState: 'Tax Lot', PropertyState: 'Property' } const mappingsMap = Object.fromEntries(this.profile.mappings.map((m) => [m.from_field, m])) - const columnNameMap = Object.fromEntries(this.columns.map((c) => [c.column_name, c.display_name])) + const columnNameMap = Object.fromEntries(this.getColumns().map((c) => [c.column_name, c.display_name])) this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { const mapping = mappingsMap[node.data.from_field] if (!mapping) return // skip if no mapping found @@ -302,7 +319,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { // at least one matching column const hasMatchingCol = toFields.some((col) => matchingColumns.includes(col)) if (!hasMatchingCol) { - const matchingColNames = this.columns.filter((c) => c.is_matching_criteria).map((c) => c.display_name).join(', ') + const matchingColNames = this.getColumns().filter((c) => c.is_matching_criteria).map((c) => c.display_name).join(', ') this.errorMessages.push(`At least one of the following Property fields is required: ${matchingColNames}.`) } diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index 8cc9c5e9..a5e2f3dd 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -28,37 +28,41 @@
-
+
Properties
- + [rowData]="propertyData" + [columnDefs]="inventoryColDefs" + [theme]="gridTheme$ | async" + [domLayout]="'autoHeight'" + [style.width.px]="400" + > +
} @if (hasTaxlotData) { - +
+ +
+
-
+
Tax Lots
- + [rowData]="taxlotData" + [columnDefs]="inventoryColDefs" + [theme]="gridTheme$ | async" + [domLayout]="'autoHeight'" + [style.width.px]="400" + > +
} diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.ts b/src/app/modules/datasets/data-mappings/step4/results.component.ts index b3671c86..bf8f81af 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/results.component.ts @@ -67,19 +67,9 @@ export class ResultsComponent implements OnChanges, OnDestroy { setGrid(results: MatchingResultsResponse) { this.matchingResults = results - this.setGeneralGrid() this.setInventoryGrids() } - setGeneralGrid() { - this.generalColDefs = [ - { field: 'import_file_records', headerName: 'Records in File' }, - { field: 'multiple_cycle_upload', headerName: 'Multi Cycle Upload' }, - ] - const { import_file_records, multiple_cycle_upload } = this.matchingResults - this.generalData = [{ import_file_records, multiple_cycle_upload }] - } - setInventoryGrids() { this.setPropertyData() this.setTaxLotData() From 0c974dcf1527018014319a74d3ec3c93b1702f44 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 15:59:28 +0000 Subject: [PATCH 34/50] show both taxlot and property results --- .../data-mappings/data-mapping.component.html | 1 - .../data-mappings/data-mapping.component.ts | 4 -- .../step3/save-mappings.component.html | 35 +++++++++--- .../step3/save-mappings.component.ts | 57 ++++++++----------- .../step4/results.component.html | 2 +- 5 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 8904f597..fcc479d3 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -61,7 +61,6 @@ [org]="org" [orgId]="orgId" (completed)="startMatchMerge()" - (inventoryTypeChange)="onInventoryTypeChange($event)" > diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index 832539bc..b7b814ad 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -250,10 +250,6 @@ export class DataMappingComponent implements OnDestroy, OnInit { }) } - onInventoryTypeChange(inventoryType: InventoryType) { - this.inventoryType = inventoryType - } - toggleHelp = () => { this.helpOpened = !this.helpOpened } diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html index a5f07b0c..39e866c9 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -23,25 +23,46 @@ class="mb-2" (click)="saveData()" color="primary" - [disabled]="!rowData.length" + [disabled]="loading" mat-raised-button - [ngClass]="{ 'animate-pulse bg-gray-500': !rowData.length }" + [ngClass]="{ 'animate-pulse bg-gray-500': loading }" >Save Data
-@if (rowData.length) { +@if (propertyResults.length) { +
+ + Properties +
-} @else { + +} +@if (taxlotResults.length) { +
+ + Tax Lots +
+ + +} + +@if (loading) {
diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 82d4c444..b89ca3f2 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -1,11 +1,14 @@ import { CommonModule } from '@angular/common' -import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatProgressBarModule } from '@angular/material/progress-bar' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { Subject, switchMap, take, tap } from 'rxjs' import type { Column } from '@seed/api/column' import type { Cycle } from '@seed/api/cycle' import { DataQualityService } from '@seed/api/data-quality' @@ -13,11 +16,8 @@ import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import type { Organization } from '@seed/api/organization' import { ConfigService } from '@seed/services' import { UploaderService } from '@seed/services/uploader' -import { AgGridAngular } from 'ag-grid-angular' -import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import { ResultsModalComponent } from 'app/modules/data-quality'; +import { ResultsModalComponent } from 'app/modules/data-quality' import type { InventoryType } from 'app/modules/inventory' -import { Subject, switchMap, take, tap } from 'rxjs' @Component({ selector: 'seed-save-mappings', @@ -39,35 +39,31 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { @Input() org: Organization @Input() orgId: number @Output() completed = new EventEmitter() - @Output() inventoryTypeChange = new EventEmitter() private _configService = inject(ConfigService) private _dataQualityService = inject(DataQualityService) private _dialog = inject(MatDialog) private _uploaderService = inject(UploaderService) private _unsubscribeAll$ = new Subject() - columnDefs: ColDef[] = [] + propertyDefs: ColDef[] = [] + taxlotDefs: ColDef[] = [] rowData: Record[] = [] - gridApi: GridApi gridTheme$ = this._configService.gridTheme$ - mappingResults: Record[] = [] + propertyResults: Record[] = [] + taxlotResults: Record[] = [] dqcComplete = false dqcId: number inventoryType: InventoryType + loading = true progressBarObj = this._uploaderService.defaultProgressBarObj ngOnChanges(changes: SimpleChanges): void { if (!changes.mappingResultsResponse?.currentValue) return - const { properties, tax_lots } = this.mappingResultsResponse - if (tax_lots.length) { - this.mappingResults = tax_lots - this.inventoryTypeChange.emit('taxlots') - } else { - this.mappingResults = properties - this.inventoryTypeChange.emit('properties') - } + this.loading = false + this.propertyResults = this.mappingResultsResponse.properties + this.taxlotResults = this.mappingResultsResponse.tax_lots this.startDQC() this.setGrid() @@ -99,14 +95,19 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { } setGrid() { - this.setColumnDefs() - this.setRowData() + if (this.propertyResults.length) { + const propertyKeys = Object.keys(this.propertyResults[0] ?? {}) + this.propertyDefs = this.setColumnDefs(propertyKeys) + } + if (this.taxlotResults.length) { + const taxlotKeys = Object.keys(this.taxlotResults[0] ?? {}) + this.taxlotDefs = this.setColumnDefs(taxlotKeys) + } } - setColumnDefs() { + setColumnDefs(keys: string[]): ColDef[] { const aliClass = 'bg-primary bg-opacity-25' - let keys = Object.keys(this.mappingResults[0] ?? {}) // remove ALI & hidden cols const excludeKeys = ['id', 'lot_number', 'raw_access_level_instance_error', ...this.org.access_level_names] keys = keys.filter((k) => !excludeKeys.includes(k)) @@ -125,25 +126,15 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { const columnNameMap: Record = this.columns.reduce((acc, { name, display_name }) => ({ ...acc, [name]: display_name }), {}) const inventoryColumnDefs = keys.map((key) => ({ field: key, headerName: columnNameMap[key] || key })) - this.columnDefs = [...hiddenColumnDefs, ...aliColumnDefs, ...inventoryColumnDefs] - } - - setRowData() { - this.rowData = this.mappingResults - } - - onGridReady(agGrid: GridReadyEvent) { - this.gridApi = agGrid.api + const columnDefs = [...hiddenColumnDefs, ...aliColumnDefs, ...inventoryColumnDefs] + return columnDefs } saveData() { - console.log('Saving data...') - // console.log(this.mappingResults) this.completed.emit() } showDataQualityResults() { - console.log('open modal showing dqc results') this._dialog.open(ResultsModalComponent, { width: '50rem', data: { orgId: this.orgId, dqcId: this.dqcId }, diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index a5e2f3dd..496c5133 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -45,7 +45,7 @@ } @if (hasTaxlotData) { -
+
From 327e632ef3f8552e4c49df1fd7995a48273c449a Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 19:22:15 +0000 Subject: [PATCH 35/50] several bug fixes, features, styles --- src/@seed/api/mapping/mapping.service.ts | 3 - src/@seed/components/page/page.component.html | 2 +- .../data-mappings/data-mapping.component.html | 9 ++- .../data-mappings/data-mapping.component.ts | 12 +++- .../data-mappings/step1/column-defs.ts | 2 + .../step1/map-data.component.html | 72 ++++++++++--------- .../data-mappings/step1/map-data.component.ts | 13 +++- .../step1/modal/create-profile.component.ts | 6 +- .../step3/save-mappings.component.html | 34 +++++---- .../step3/save-mappings.component.ts | 1 + .../step4/match-merge.component.ts | 3 +- .../step4/results.component.html | 7 +- .../data-upload-modal.component.ts | 11 +-- .../property-taxlot-upload.component.html | 4 +- .../property-taxlot-upload.component.ts | 27 +++++-- .../datasets/dataset/dataset.component.ts | 3 - .../modules/datasets/datasets.component.ts | 24 ++++--- .../list/grid/grid.component.ts | 11 +-- 18 files changed, 149 insertions(+), 95 deletions(-) diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index c01edf42..01862c9c 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -70,9 +70,6 @@ export class MappingService { const url = `/api/v3/import_files/${importFileId}/mapping_results/?organization_id=${orgId}` return this._httpClient.post(url, {}) .pipe( - tap((response) => { - console.log(response) - }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching mapping results') }), diff --git a/src/@seed/components/page/page.component.html b/src/@seed/components/page/page.component.html index c7f8c40b..3142e1b7 100644 --- a/src/@seed/components/page/page.component.html +++ b/src/@seed/components/page/page.component.html @@ -80,7 +80,7 @@

-
+
diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index fcc479d3..82b2a9d6 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -29,7 +29,12 @@ - + + + check + + + @@ -61,6 +67,7 @@ [org]="org" [orgId]="orgId" (completed)="startMatchMerge()" + (backToMapping)="backToMapping()" > diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index b7b814ad..c8618c1c 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -30,7 +30,7 @@ import { UserService } from '@seed/api/user' import { PageComponent, ProgressBarComponent } from '@seed/components' import { UploaderService } from '@seed/services/uploader' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { InventoryType, Profile } from 'app/modules/inventory' +import type { InventoryDisplayType, InventoryType, Profile } from 'app/modules/inventory' import { HelpComponent } from './help.component' import { MapDataComponent } from './step1/map-data.component' import { SaveMappingsComponent } from './step3/save-mappings.component' @@ -241,6 +241,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { startMatchMerge() { this.nextStep(3) this.matchMergeComponent.startMatchMerge() + this.completed[4] = true } nextStep(currentStep: number) { @@ -250,10 +251,19 @@ export class DataMappingComponent implements OnDestroy, OnInit { }) } + backToMapping() { + this.stepper.selectedIndex = 0 + this.completed = { 1: false, 2: false, 3: false, 4: false } + } + toggleHelp = () => { this.helpOpened = !this.helpOpened } + onDefaultInventoryTypeChange(value: InventoryDisplayType) { + this.inventoryType = value === 'Tax Lot' ? 'taxlots' : 'properties' + } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/app/modules/datasets/data-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts index a6c07089..b39218ec 100644 --- a/src/app/modules/datasets/data-mappings/step1/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -54,6 +54,7 @@ export const buildColumnDefs = ( uploadedFilename: string, seedHeaderChange: (event: CellValueChangedEvent) => void, dataTypeChange: (event: CellValueChangedEvent) => void, + validateData: (event?: CellValueChangedEvent) => void, ): (ColDef | ColGroupDef)[] => { const seedCols: ColDef[] = [ { field: 'isExtraData', hide: true }, @@ -66,6 +67,7 @@ export const buildColumnDefs = ( cellEditor: 'agCheckboxCellEditor', editable: true, width: 70, + onCellValueChanged: validateData, }, { field: 'to_table_name', 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 9a80f341..749a006e 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 @@ -1,46 +1,50 @@ -
+
+ -
-
Cycle:
-
{{ cycle?.name }}
-
- - -
-
Column Profile:
- - - @for (profile of columnMappingProfiles; track profile.id) { - {{ profile.name }} - } - - - - - - - - - - - -
+ +
+
Cycle:
+
{{ cycle?.name }}
+
+
+ + +
+
Column Profile:
+ + + @for (profile of columnMappingProfiles; track profile.id) { + {{ profile.name }} + } + + + + + + + + + + + +
-
- +
- Property Types - Tax Lot Types + Properties + Tax Lots + +
diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index e5ec3149..2b6aa226 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -65,6 +65,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { @Input() matchingPropertyColumns: string[] @Input() matchingTaxLotColumns: string[] @Output() completed = new EventEmitter() + @Output() defaultInventoryTypeChange = new EventEmitter() private readonly _unsubscribeAll$ = new Subject() private _configService = inject(ConfigService) @@ -129,6 +130,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { this.importFile.uploaded_filename, this.seedHeaderChange.bind(this), this.dataTypeChange.bind(this), + this.validateData.bind(this), ) } @@ -156,6 +158,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { setAllInventoryType(value: InventoryDisplayType) { this.defaultInventoryType = value + this.defaultInventoryTypeChange.emit(value) this.gridApi.forEachNode((node) => node.setDataValue('to_table_name', value)) this.setColumns() } @@ -242,13 +245,17 @@ export class MapDataComponent implements OnChanges, OnDestroy { saveProfile() { // overwrite the existing profile + this.profile.mappings = this.getMappingsFromGrid() + this._columnMappingProfileService.update(this.orgId, this.profile).subscribe() + } + + getMappingsFromGrid(): ColumnMapping[] { const mappings: ColumnMapping[] = [] this.gridApi.forEachNode((node) => { const mapping = this.formatRowToMapping(node.data) if (mapping) mappings.push(mapping) }) - this.profile.mappings = mappings - this._columnMappingProfileService.update(this.orgId, this.profile).subscribe() + return mappings } formatRowToMapping(row: Record): ColumnMapping { @@ -265,7 +272,6 @@ export class MapDataComponent implements OnChanges, OnDestroy { } createProfile() { - console.log('create profile') const profileType: ColumnMappingProfileType = this.importFile.source_type === 'BuildingSync' ? 'BuildingSync Custom' : 'Normal' const profileTypes: ColumnMappingProfileType[] = profileType === 'BuildingSync Custom' ? ['BuildingSync Default', 'BuildingSync Custom'] : ['Normal'] const dialogRef = this._dialog.open(CreateProfileComponent, { @@ -274,6 +280,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { existingNames: this.columnMappingProfiles.map((p) => p.name), orgId: this.orgId, profileType, + mappings: this.getMappingsFromGrid(), }, }) diff --git a/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts index 9aec2747..7060627c 100644 --- a/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.ts @@ -6,7 +6,7 @@ import { MatDividerModule } from '@angular/material/divider' import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' -import type { ColumnMappingProfile, ColumnMappingProfileType } from '@seed/api/column_mapping_profile' +import type { ColumnMapping, ColumnMappingProfile, ColumnMappingProfileType } from '@seed/api/column_mapping_profile' import { ColumnMappingProfileService } from '@seed/api/column_mapping_profile' import { SEEDValidators } from '@seed/validators' @@ -31,7 +31,7 @@ export class CreateProfileComponent { profileName = '' profile: ColumnMappingProfile - data = inject(MAT_DIALOG_DATA) as { orgId: number; profileType: ColumnMappingProfileType; existingNames: string[] } + data = inject(MAT_DIALOG_DATA) as { orgId: number; profileType: ColumnMappingProfileType; mappings: ColumnMapping[]; existingNames: string[] } form = new FormGroup({ name: new FormControl('', [Validators.required, SEEDValidators.uniqueValue(this.data.existingNames)]), }) @@ -40,7 +40,7 @@ export class CreateProfileComponent { this.profile = { name: this.form.value.name, profile_type: this.data.profileType, - mappings: [], + mappings: this.data.mappings, } as ColumnMappingProfile this._columnMappingProfileService.create(this.data.orgId, this.profile) diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html index 39e866c9..838e36cc 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -5,22 +5,19 @@ - +
-
-
-
Access Level Info
+
+
- -
+
-
+ @if (propertyResults.length) { -
+
- Properties + Properties +
+
+
+
Access Level Info
+
+ } @if (taxlotResults.length) { -
+ +
- Tax Lots + Tax Lots +
+
+
+
Access Level Info
+
() + @Output() backToMapping = new EventEmitter() private _configService = inject(ConfigService) private _dataQualityService = inject(DataQualityService) diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index a357adea..4c66d495 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -10,6 +10,7 @@ import { ProgressBarComponent } from '@seed/components' import type { CheckProgressLoopParams} from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' import { ResultsComponent } from './results.component' +import { InventoryType } from 'app/modules/inventory' @Component({ selector: 'seed-match-merge', @@ -25,7 +26,7 @@ import { ResultsComponent } from './results.component' export class MatchMergeComponent implements OnDestroy { @Input() importFileId: number @Input() orgId: number - @Input() inventoryType + @Input() inventoryType: InventoryType private _mappingService = inject(MappingService) private _uploaderService = inject(UploaderService) diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index 496c5133..d0dfc350 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -1,7 +1,12 @@
@if ( matchingResults) { -
+
+ + + @@ -54,7 +54,7 @@
- diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts index 3a07adc8..658d92f3 100644 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts @@ -16,10 +16,9 @@ import { MatSelectModule } from '@angular/material/select' import { MatStepper, MatStepperModule } from '@angular/material/stepper' import { Router, RouterModule } from '@angular/router' import { Cycle } from '@seed/api/cycle' -import { CycleService } from '@seed/api/cycle/cycle.service' import type { Dataset } from '@seed/api/dataset' -import { DatasetService } from '@seed/api/dataset' -import { ProgressResponse } from '@seed/api/progress' +import { OrganizationService, OrganizationUserSettings } from '@seed/api/organization' +import { UserService } from '@seed/api/user' import { ProgressBarComponent } from '@seed/components' import { ErrorService } from '@seed/services' import { ProgressBarObj, UploaderService } from '@seed/services/uploader' @@ -53,9 +52,10 @@ export class PropertyTaxlotUploadComponent implements AfterViewInit, OnDestroy { @Input() dataset: Dataset @Input() orgId: number @Output() dismissModal = new EventEmitter() - private _datasetService = inject(DatasetService) - private _cycleService = inject(CycleService) + + private _organizationService = inject(OrganizationService) private _uploaderService = inject(UploaderService) + private _userService = inject(UserService) private _errorService = inject(ErrorService) private _router = inject(Router) private _snackBar = inject(SnackBarService) @@ -65,7 +65,7 @@ export class PropertyTaxlotUploadComponent implements AfterViewInit, OnDestroy { file: File fileId: number inProgress = false - uploading = false + orgUserId: number progressBarObj: ProgressBarObj = { message: [], progress: 0, @@ -76,14 +76,25 @@ export class PropertyTaxlotUploadComponent implements AfterViewInit, OnDestroy { progressLastChecked: null, } sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw' + uploading = false + userSettings: OrganizationUserSettings = {} form = new FormGroup({ cycleId: new FormControl(null, Validators.required), multiCycle: new FormControl(false), }) - + ngAfterViewInit() { this.form.patchValue({ cycleId: this.cycles[0]?.id }) + this._userService.currentUser$ + .pipe( + takeUntil(this._unsubscribeAll$), + tap((user) => { + this.orgUserId = user.org_user_id + this.userSettings = user.settings + }), + ) + .subscribe() } step1(fileList: FileList) { @@ -91,6 +102,8 @@ export class PropertyTaxlotUploadComponent implements AfterViewInit, OnDestroy { const cycleId = this.form.get('cycleId')?.value const multiCycle = this.form.get('multiCycle')?.value this.uploading = true + this.userSettings.cycleId = cycleId + this._organizationService.updateOrganizationUser(this.orgUserId, this.orgId, this.userSettings).subscribe() return this._uploaderService .fileUpload(this.orgId, this.file, this.sourceType, this.dataset.id.toString()) diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 1b283ef7..902f6904 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -58,7 +58,6 @@ export class DatasetComponent implements OnDestroy, OnInit { tap((cycles) => { this.cycles = cycles this.cyclesMap = cycles.reduce((acc, c) => ({ ...acc, [c.id]: c.name }), {}) - console.log('cyclesMap', this.cyclesMap) }), ) } @@ -130,7 +129,6 @@ export class DatasetComponent implements OnDestroy, OnInit { this.downloadDocument(importFile.file, importFile.uploaded_filename) } else if (action === 'dataMapping') { void this._router.navigate(['/data/mappings/', importFile.id]) - console.log('data mapping', importFile) } else if (action === 'dataPairing') { console.log('data pairing', importFile) } @@ -150,7 +148,6 @@ export class DatasetComponent implements OnDestroy, OnInit { } downloadDocument(file: string, filename: string) { - console.log('file', file) const a = document.createElement('a') const url = file a.href = url diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index 4c10365d..451136cf 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common' -import type { OnInit } from '@angular/core' +import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject, ViewEncapsulation } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MatDialog } from '@angular/material/dialog' @@ -7,7 +7,7 @@ import { MatIconModule } from '@angular/material/icon' import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import { filter, switchMap, tap } from 'rxjs' +import { combineLatest, filter, Subject, switchMap, takeUntil, tap } from 'rxjs' import type { Cycle } from '@seed/api/cycle' import { CycleService } from '@seed/api/cycle/cycle.service' import { type Dataset, DatasetService } from '@seed/api/dataset' @@ -22,7 +22,6 @@ import { FormModalComponent } from './modal/form-modal.component' selector: 'seed-data', templateUrl: './datasets.component.html', encapsulation: ViewEncapsulation.None, - // changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AgGridAngular, CommonModule, @@ -31,7 +30,7 @@ import { FormModalComponent } from './modal/form-modal.component' PageComponent, ], }) -export class DatasetsComponent implements OnInit { +export class DatasetsComponent implements OnDestroy, OnInit { private _configService = inject(ConfigService) private _cycleService = inject(CycleService) private _datasetService = inject(DatasetService) @@ -39,6 +38,7 @@ export class DatasetsComponent implements OnInit { private _router = inject(Router) private _userService = inject(UserService) private _dialog = inject(MatDialog) + private readonly _unsubscribeAll$ = new Subject() columnDefs: ColDef[] cycles: Cycle[] = [] datasets: Dataset[] @@ -61,14 +61,17 @@ export class DatasetsComponent implements OnInit { this.orgId = orgId this._datasetService.list(orgId) }), - switchMap(() => this._cycleService.cycles$), - tap((cycles) => { this.cycles = cycles }), - switchMap(() => this._datasetService.datasets$), - tap((datasets) => { + switchMap(() => combineLatest([ + this._cycleService.cycles$, + this._datasetService.datasets$, + ])), + tap(([cycles, datasets]) => { + this.cycles = cycles this.datasets = datasets.sort((a, b) => naturalSort(a.name, b.name)) this.existingNames = datasets.map((ds) => ds.name) this.setColumnDefs() }), + takeUntil(this._unsubscribeAll$), ).subscribe() } @@ -163,4 +166,9 @@ export class DatasetsComponent implements OnInit { trackByFn(_index: number, { id }: Dataset) { return id } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } } 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 6495f2f4..efe1129b 100644 --- a/src/app/modules/inventory-list/list/grid/grid.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid.component.ts @@ -89,12 +89,13 @@ export class InventoryGridComponent implements OnChanges { const target = event.event.target as HTMLElement const action = target.getAttribute('data-action') as 'detail' | 'notes' | 'meters' | null if (!action) return - const { property_view_id } = event.data as { property_view_id: string; file: string; filename: string } + const { property_view_id, taxlot_view_id } = event.data as { property_view_id: string; taxlot_view_id: string } + const viewId = property_view_id || taxlot_view_id const urlMap = { - detail: [`/${this.inventoryType}`, property_view_id], - notes: [`/${this.inventoryType}`, property_view_id, 'notes'], - meters: [`/${this.inventoryType}`, property_view_id, 'meters'], + detail: [`/${this.inventoryType}`, viewId], + notes: [`/${this.inventoryType}`, viewId, 'notes'], + meters: [`/${this.inventoryType}`, viewId, 'meters'], } return void this._router.navigate(urlMap[action]) @@ -148,7 +149,7 @@ export class InventoryGridComponent implements OnChanges { actionRenderer = (value, icon: string, action: string) => { if (!value) return '' - // Allow a single letter to be passed as an indicator + // Allow a single letter to be passed as an indicator (like G for groups) if (icon.length === 1) { return `${icon}` } From 2f5d86c6aea6e1191114ce8276e02992e0b8e38b Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 19:22:35 +0000 Subject: [PATCH 36/50] prettier --- .../ag-grid/autocomplete.component.html | 9 +- .../progress/progress-bar.component.html | 16 ++-- .../data-quality/results-modal.component.html | 6 +- .../data-mappings/data-mapping.component.html | 62 +++++------- .../data-mappings/help.component.html | 50 ++++------ .../step1/map-data.component.html | 96 +++++++++---------- .../step1/modal/create-profile.component.html | 4 +- .../step3/save-mappings.component.html | 64 ++++++------- .../step4/match-merge.component.html | 18 ++-- .../step4/results.component.html | 25 ++--- .../data-upload-modal.component.html | 8 +- .../property-taxlot-upload.component.html | 59 +++++------- .../datasets/dataset/dataset.component.html | 4 +- .../modules/datasets/datasets.component.html | 28 +++--- .../datasets/modal/form-modal.component.html | 4 +- .../green-button-upload-modal.component.html | 6 +- .../modal/more-actions-modal.component.html | 14 +-- 17 files changed, 207 insertions(+), 266 deletions(-) diff --git a/src/@seed/components/ag-grid/autocomplete.component.html b/src/@seed/components/ag-grid/autocomplete.component.html index 61e84dd6..f24ce47b 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.html +++ b/src/@seed/components/ag-grid/autocomplete.component.html @@ -1,10 +1,5 @@ - + @for (option of filteredOptions; track $index) { @@ -12,4 +7,4 @@ } - \ No newline at end of file + diff --git a/src/@seed/components/progress/progress-bar.component.html b/src/@seed/components/progress/progress-bar.component.html index 8ffeecdd..1e31e892 100644 --- a/src/@seed/components/progress/progress-bar.component.html +++ b/src/@seed/components/progress/progress-bar.component.html @@ -11,8 +11,12 @@
}
- - + +
@if (showSubProgress && subProgress && subProgress < 100) { @@ -22,13 +26,13 @@
{{ subTitle }}
@if (subProgress) { -
- {{ subProgressString }} -
+
+ {{ subProgressString }} +
}
- +
}
diff --git a/src/app/modules/data-quality/results-modal.component.html b/src/app/modules/data-quality/results-modal.component.html index 9bcc354f..777c8ac1 100644 --- a/src/app/modules/data-quality/results-modal.component.html +++ b/src/app/modules/data-quality/results-modal.component.html @@ -12,13 +12,13 @@ [domLayout]="'autoHeight'" [pagination]="true" [paginationPageSize]="10" - > + > } @else {
No warnings or errors
}
-
+
-
\ No newline at end of file +
diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 82b2a9d6..4f57ef1a 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -8,18 +8,15 @@ }" >
- -
- @@ -27,7 +24,6 @@ - @@ -36,51 +32,39 @@ - + - + - + - - - - - \ No newline at end of file + diff --git a/src/app/modules/datasets/data-mappings/help.component.html b/src/app/modules/datasets/data-mappings/help.component.html index 4727e294..dc2d5ae9 100644 --- a/src/app/modules/datasets/data-mappings/help.component.html +++ b/src/app/modules/datasets/data-mappings/help.component.html @@ -1,55 +1,37 @@
-
- MAPPING YOUR DATA TO SEED -
- -
- It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to - type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as - well as typing in the field name from the original datafile. -
+
MAPPING YOUR DATA TO SEED
- In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will - affect how the data is matched and merged, as well as how it is displayed in the Inventory view. + It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is + based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name + from the original datafile.
- Column Mapping Profiles can be used to help you easily and consistently map your data. Note that file header columns - defined in the profile must match exactly (spaces, lowercase, uppercase, etc.) in order for the corresponding SEED - column information to be used. + In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data + is matched and merged, as well as how it is displayed in the Inventory view.
- Field names for matching Properties: Custom ID 1, PM Property ID - + Column Mapping Profiles can be used to help you easily and consistently map your data. Note that file header columns defined in the + profile must match exactly (spaces, lowercase, uppercase, etc.) in order for the corresponding SEED column information to be used.
-
- Field names for matching Tax Lots: Custom ID 1, Jurisdiction Tax Lot ID -
+
Field names for matching Properties: Custom ID 1, PM Property ID
+ +
Field names for matching Tax Lots: Custom ID 1, Jurisdiction Tax Lot ID
- If there are fields in the datafile mapped to these names, the program will attempt to match on the corresponding values - in existing records. All of these fields must have the same values between records for the records to match. + If there are fields in the datafile mapped to these names, the program will attempt to match on the corresponding values in existing + records. All of these fields must have the same values between records for the records to match.
- Matches within the same cycle will be merged together, while matches in different cycles will be associated for - cross-cycle analysis. + Matches within the same cycle will be merged together, while matches in different cycles will be associated for cross-cycle analysis.
- When you click the Map Your Data button, the program will show a grid with the new field names as the column headings - and your data in the rows. In that view, you can still come back to the initial mapping screen and change the field - mapping. + When you click the Map Your Data button, the program will show a grid with the new field names as the column headings and your data in + the rows. In that view, you can still come back to the initial mapping screen and change the field mapping.
-
- - - - - - - 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 749a006e..51d6bd11 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 @@ -1,64 +1,64 @@
- -
-
Cycle:
-
{{ cycle?.name }}
-
-
- - -
+
+
Cycle:
+
{{ cycle?.name }}
+
+
+ +
+
Column Profile:
+ + + @for (profile of columnMappingProfiles; track profile.id) { + {{ profile.name }} + } + + + + + + + + + + + +
-
- - - Properties - Tax Lots - - - -
+
+ + + Properties + Tax Lots + + +
-
-
-
-
Editable Cell
+
+
+
+
Editable Cell
- @if (errorMessages.length ) { - + @if (errorMessages.length) { +
    @for (error of errorMessages; track $index) {
  • {{ error }}
  • @@ -68,7 +68,7 @@ }
- +
- \ No newline at end of file + diff --git a/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html index 3e8e3600..a4549cf8 100644 --- a/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html +++ b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html @@ -7,7 +7,6 @@
- Name @@ -15,7 +14,6 @@ This name already exists. } -
@@ -27,4 +25,4 @@ -
\ No newline at end of file +
diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html index 838e36cc..06f8c2a7 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -1,41 +1,43 @@ -
+
Cycle:
{{ cycle?.name }}
- - - -
+ +
- +
-
- - + + (click)="saveData()" + color="primary" + mat-raised-button + > + Save Data +
@if (propertyResults.length) { -
+
Properties
-
-
-
Access Level Info
+
+
+
Access Level Info
@@ -45,20 +47,19 @@ [theme]="gridTheme$ | async" [pagination]="true" [paginationPageSize]="20" - [domLayout]="'autoHeight'" + [domLayout]="'autoHeight'" > - - -} + + +} @if (taxlotResults.length) { - -
+
Tax Lots
-
-
-
Access Level Info
+
+
+
Access Level Info
} @@ -80,5 +81,4 @@
- -} \ No newline at end of file +} diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html index cb23f6f5..a4c07be5 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -1,14 +1,14 @@
@if (inProgress) { + [progress]="progressBarObj.progress" + [total]="progressBarObj.total" + [title]="progressBarObj.statusMessage" + [showSubProgress]="true" + [subProgress]="subProgressBarObj.progress" + [subTitle]="subProgressBarObj.statusMessage" + [subTotal]="subProgressBarObj.total" + > } @else { } -
\ No newline at end of file +
diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index d0dfc350..bf78791b 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -1,16 +1,11 @@ -
- - @if ( matchingResults) { +
-
+
Properties
- +
-
+
Tax Lots
- +
} - - -
\ No newline at end of file +
diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.html b/src/app/modules/datasets/data-upload/data-upload-modal.component.html index 2bab5be2..2fbf5148 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -1,7 +1,8 @@
+ class="flex h-10 w-10 flex-0 items-center justify-center rounded-full bg-primary-100 text-primary-600 sm:mr-4 dark:bg-primary-600 dark:text-primary-50" + >
@@ -11,7 +12,8 @@
+ (click)="dismiss()" + >
@@ -22,4 +24,4 @@ [dataset]="data.dataset" [orgId]="data.orgId" (dismissModal)="dismiss()" -> \ No newline at end of file +> diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html index 108e56ce..a8d1677b 100644 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html @@ -1,71 +1,64 @@ -
-
- +
- {{ form.get('multiCycle').value ? 'Default Cycle' : 'Cycle'}} + {{ form.get('multiCycle').value ? 'Default Cycle' : 'Cycle' }} @for (cycle of cycles; track $index) { - {{ cycle.name }} + {{ cycle.name }} } - - Multi-Cycle - + Multi-Cycle
- - -
+ +
- .csv, .xls, .xslx
-
Note: only the first sheet of multi-sheet Excel files will be imported.
+
Note: only the first sheet of multi-sheet Excel files will be imported.
-
- .geojson, .json
- +
- .xml
- -
- + @if (uploading) { } @else { @@ -73,28 +66,22 @@
} - + - - @if (inProgress) { - - } + + @if (inProgress) { + + } - -
+
-
- - \ No newline at end of file + diff --git a/src/app/modules/datasets/dataset/dataset.component.html b/src/app/modules/datasets/dataset/dataset.component.html index 84a6b9f6..70b1fb8e 100644 --- a/src/app/modules/datasets/dataset/dataset.component.html +++ b/src/app/modules/datasets/dataset/dataset.component.html @@ -3,7 +3,7 @@ title: 'Dataset', subTitle: datasetName$ | async, titleIcon: 'fa-solid:sitemap', - breadcrumbs: ['Dataset', 'Detail'] + breadcrumbs: ['Dataset', 'Detail'], }" >
@@ -20,4 +20,4 @@ > }
- \ No newline at end of file + diff --git a/src/app/modules/datasets/datasets.component.html b/src/app/modules/datasets/datasets.component.html index 4901d1d5..3eda6111 100644 --- a/src/app/modules/datasets/datasets.component.html +++ b/src/app/modules/datasets/datasets.component.html @@ -7,18 +7,18 @@ actionText: 'Create Dataset', }" > -
- @if (datasets.length) { - - } -
+
+ @if (datasets.length) { + + } +
diff --git a/src/app/modules/datasets/modal/form-modal.component.html b/src/app/modules/datasets/modal/form-modal.component.html index 63a93e85..36b4eaa5 100644 --- a/src/app/modules/datasets/modal/form-modal.component.html +++ b/src/app/modules/datasets/modal/form-modal.component.html @@ -9,7 +9,7 @@ Dataset Name @if (form.controls.name?.hasError('valueExists')) { - This name already exists. + This name already exists. } @@ -21,4 +21,4 @@ -
\ No newline at end of file +
diff --git a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html index c4c8858a..f798b529 100644 --- a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html +++ b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html @@ -86,11 +86,7 @@ @if (inProgress) { - + } diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html index c15e3974..0281e76c 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.html @@ -39,16 +39,16 @@ @if (showProgress) { -
+
- + class="w-full" + [progress]="progressBarObj.progress" + [total]="progressBarObj.total" + [title]="progressBarObj.statusMessage || 'In Progress...'" + > +
} -
From 7b0d32c263878b5b26b7e129ae87c03b95b288f7 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 9 Jul 2025 19:35:46 +0000 Subject: [PATCH 37/50] lint --- .../api/data-quality/data-quality.service.ts | 2 +- src/@seed/api/dataset/dataset.service.ts | 4 ++-- src/@seed/api/mapping/mapping.service.ts | 10 ++++---- src/@seed/components/ag-grid/index.ts | 2 +- .../services/uploader/uploader.service.ts | 6 ++--- .../data-quality/results-modal.component.ts | 6 ++--- .../data-mappings/data-mapping.component.ts | 3 +-- .../data-mappings/step1/column-defs.ts | 6 ++--- .../data-mappings/step1/map-data.component.ts | 4 ++-- .../step3/save-mappings.component.ts | 2 +- .../step4/match-merge.component.ts | 4 ++-- .../data-upload-modal.component.ts | 2 +- .../property-taxlot-upload.component.ts | 23 +++++++++++-------- src/app/modules/datasets/index.ts | 2 +- 14 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/@seed/api/data-quality/data-quality.service.ts b/src/@seed/api/data-quality/data-quality.service.ts index 262dce0e..f26f5ac4 100644 --- a/src/@seed/api/data-quality/data-quality.service.ts +++ b/src/@seed/api/data-quality/data-quality.service.ts @@ -4,8 +4,8 @@ import { catchError, map, type Observable, ReplaySubject, switchMap, tap } from import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import type { DataQualityResults, DataQualityResultsResponse, Rule } from './data-quality.types' import type { DQCProgressResponse } from '../progress' +import type { DataQualityResults, DataQualityResultsResponse, Rule } from './data-quality.types' @Injectable({ providedIn: 'root' }) export class DataQualityService { diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index c032b0ce..68b8538f 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -3,10 +3,10 @@ import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' import { catchError, map, ReplaySubject, tap } from 'rxjs' -import { UserService } from '../user' -import type { CountDatasetsResponse, Dataset, DatasetResponse, ImportFile, ImportFileResponse, ListDatasetsResponse } from './dataset.types' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { UserService } from '../user' +import type { CountDatasetsResponse, Dataset, DatasetResponse, ImportFile, ImportFileResponse, ListDatasetsResponse } from './dataset.types' @Injectable({ providedIn: 'root' }) export class DatasetService { diff --git a/src/@seed/api/mapping/mapping.service.ts b/src/@seed/api/mapping/mapping.service.ts index 01862c9c..60cc1040 100644 --- a/src/@seed/api/mapping/mapping.service.ts +++ b/src/@seed/api/mapping/mapping.service.ts @@ -1,12 +1,12 @@ -import type { HttpErrorResponse } from '@angular/common/http'; +import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' +import { catchError, map, type Observable } from 'rxjs' import { ErrorService } from '@seed/services' +import type { MappedData, MappingResultsResponse } from '../dataset' +import type { ProgressResponse, SubProgressResponse } from '../progress' import { UserService } from '../user' -import { catchError, map, tap, type Observable } from 'rxjs' import type { FirstFiveRowsResponse, MappingSuggestionsResponse, MatchingResultsResponse, RawColumnNamesResponse } from './mapping.types' -import { MappedData, MappingResultsResponse } from '../dataset' -import type { ProgressResponse, SubProgressResponse } from '../progress' @Injectable({ providedIn: 'root' }) export class MappingService { @@ -28,7 +28,7 @@ export class MappingService { const url = `/api/v3/import_files/${importFileId}/raw_column_names/?organization_id=${orgId}` return this._httpClient.get(url) .pipe( - map(({ raw_columns }) => raw_columns ), + map(({ raw_columns }) => raw_columns), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching raw column names') }), diff --git a/src/@seed/components/ag-grid/index.ts b/src/@seed/components/ag-grid/index.ts index 82292220..84938b4e 100644 --- a/src/@seed/components/ag-grid/index.ts +++ b/src/@seed/components/ag-grid/index.ts @@ -1 +1 @@ -export * from './editHeader.component' \ No newline at end of file +export * from './editHeader.component' diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index b1cb1aae..2c106d94 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.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 { catchError, combineLatest, filter, finalize, interval, map, of, repeat, startWith, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' +import { catchError, combineLatest, finalize, interval, of, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' import type { ProgressResponse } from '@seed/api/progress' import { ErrorService } from '../error' import type { @@ -70,7 +70,7 @@ export class UploaderService { } /* - * Check the progress of Main Progress and its Sub Progress + * Check the progress of Main Progress and its Sub Progress * Main progress will run until it completes * Sub Progresses can complete several times and will run continuously until Main Progress is completed * the stop$ stream is used to end the Sub Progress stream @@ -106,8 +106,6 @@ export class UploaderService { ) } - - greenButtonMetersPreview(orgId: number, viewId: number, systemId: number, fileId: number): Observable { const url = `/api/v3/import_files/${fileId}/greenbutton_meters_preview/` const params: Record = { organization_id: orgId } diff --git a/src/app/modules/data-quality/results-modal.component.ts b/src/app/modules/data-quality/results-modal.component.ts index 64fd81be..d2387903 100644 --- a/src/app/modules/data-quality/results-modal.component.ts +++ b/src/app/modules/data-quality/results-modal.component.ts @@ -5,12 +5,12 @@ import { MatButtonModule } from '@angular/material/button' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' -import type { DataQualityResults } from '@seed/api/data-quality' -import { DataQualityService } from '@seed/api/data-quality' -import { ConfigService } from '@seed/services' import { AgGridAngular } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' import { Subject, takeUntil, tap } from 'rxjs' +import type { DataQualityResults } from '@seed/api/data-quality' +import { DataQualityService } from '@seed/api/data-quality' +import { ConfigService } from '@seed/services' @Component({ selector: 'seed-data-quality-results', diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index c8618c1c..e9f62ae4 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { CommonModule } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject, ViewChild } from '@angular/core' @@ -23,7 +22,7 @@ import type { ImportFile, MappingResultsResponse } from '@seed/api/dataset' import { DatasetService } from '@seed/api/dataset' import type { MappingSuggestionsResponse } from '@seed/api/mapping' import { MappingService } from '@seed/api/mapping' -import type { Organization } from '@seed/api/organization'; +import type { Organization } from '@seed/api/organization' import { OrganizationService } from '@seed/api/organization' import type { ProgressResponse } from '@seed/api/progress' import { UserService } from '@seed/api/user' diff --git a/src/app/modules/datasets/data-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts index b39218ec..d474d645 100644 --- a/src/app/modules/datasets/data-mappings/step1/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -89,9 +89,9 @@ export const buildColumnDefs = ( field: 'to_field_display_name', headerName: 'SEED Header', cellEditor: AutocompleteCellComponent, - cellEditorParams: (params) => { - return { values: getColumnOptions(params, propertyColumnNames, taxlotColumnNames)} - } , + cellEditorParams: (params: ICellRendererParams) => { + return { values: getColumnOptions(params, propertyColumnNames, taxlotColumnNames) } + }, headerComponent: EditHeaderComponent, headerComponentParams: { name: 'SEED Header', diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index 2b6aa226..5398b9fc 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -18,8 +18,8 @@ import { AgGridAngular } from 'ag-grid-angular' import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' import { Subject, switchMap, take } from 'rxjs' import { type Column } from '@seed/api/column' -import type { ColumnMappingProfileType } from '@seed/api/column_mapping_profile' -import { type ColumnMapping, type ColumnMappingProfile, ColumnMappingProfileService } from '@seed/api/column_mapping_profile' +import type { ColumnMapping, ColumnMappingProfile, ColumnMappingProfileType } from '@seed/api/column_mapping_profile' +import { ColumnMappingProfileService } from '@seed/api/column_mapping_profile' import type { Cycle } from '@seed/api/cycle' import type { DataMappingRow, ImportFile } from '@seed/api/dataset' import type { MappingSuggestionsResponse } from '@seed/api/mapping' diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 221e0a25..0df05ba1 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common' import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatButtonModule } from '@angular/material/button' -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatProgressBarModule } from '@angular/material/progress-bar' diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index 4c66d495..b7a3c6cd 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -7,10 +7,10 @@ import { of, Subject, switchMap, takeUntil } from 'rxjs' import { MappingService } from '@seed/api/mapping' import type { ProgressResponse, SubProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' -import type { CheckProgressLoopParams} from '@seed/services/uploader' +import type { CheckProgressLoopParams } from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' +import type { InventoryType } from 'app/modules/inventory' import { ResultsComponent } from './results.component' -import { InventoryType } from 'app/modules/inventory' @Component({ selector: 'seed-match-merge', diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts index 24b023df..59ab7586 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -3,9 +3,9 @@ import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' +import type { Cycle } from '@seed/api/cycle' import type { Dataset } from '@seed/api/dataset' import { PropertyTaxlotUploadComponent } from './property-taxlot-upload.component' -import { Cycle } from '@seed/api/cycle' @Component({ selector: 'seed-data-upload-modal', diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts index 658d92f3..ce060824 100644 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts @@ -1,29 +1,32 @@ -import { StepperSelectionEvent } from '@angular/cdk/stepper' +import type { StepperSelectionEvent } from '@angular/cdk/stepper' import { CommonModule } from '@angular/common' -import { HttpErrorResponse } from '@angular/common/http' -import type { AfterViewInit, ElementRef, OnDestroy, OnInit } from '@angular/core' +import type { HttpErrorResponse } from '@angular/common/http' +import type { AfterViewInit, ElementRef, OnDestroy } from '@angular/core' import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core' import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' -import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox' -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { MatDialogModule } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { MatProgressBarModule } from '@angular/material/progress-bar' import { MatSelectModule } from '@angular/material/select' -import { MatStepper, MatStepperModule } from '@angular/material/stepper' +import type { MatStepper } from '@angular/material/stepper' +import { MatStepperModule } from '@angular/material/stepper' import { Router, RouterModule } from '@angular/router' -import { Cycle } from '@seed/api/cycle' +import { catchError, Subject, switchMap, takeUntil, tap } from 'rxjs' +import type { Cycle } from '@seed/api/cycle' import type { Dataset } from '@seed/api/dataset' -import { OrganizationService, OrganizationUserSettings } from '@seed/api/organization' +import type { OrganizationUserSettings } from '@seed/api/organization' +import { OrganizationService } from '@seed/api/organization' import { UserService } from '@seed/api/user' import { ProgressBarComponent } from '@seed/components' import { ErrorService } from '@seed/services' -import { ProgressBarObj, UploaderService } from '@seed/services/uploader' +import type { ProgressBarObj } from '@seed/services/uploader' +import { UploaderService } from '@seed/services/uploader' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import { catchError, Subject, switchMap, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-property-taxlot-upload', diff --git a/src/app/modules/datasets/index.ts b/src/app/modules/datasets/index.ts index 7a4e3855..944275fc 100644 --- a/src/app/modules/datasets/index.ts +++ b/src/app/modules/datasets/index.ts @@ -1,3 +1,3 @@ export * from './datasets.component' export * from './dataset' -export * from './data-mappings' \ No newline at end of file +export * from './data-mappings' From 7f23d9308ff35007046aa7819f0940fd3869cd09 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 10 Jul 2025 19:20:48 +0000 Subject: [PATCH 38/50] shared modal header, reorg, base for meter upload --- src/@seed/api/dataset/dataset.types.ts | 15 +- .../delete-modal/delete-modal.component.html | 8 +- .../delete-modal/delete-modal.component.ts | 3 +- src/@seed/components/index.ts | 1 + src/@seed/components/modal/index.ts | 1 + .../modal/modal-header.component.html | 22 ++ .../modal/modal-header.component.ts | 21 ++ .../data-upload-modal.component.html | 108 +++++++--- .../data-upload-modal.component.ts | 193 ++++++++++++++++- .../meter-upload-modal.component.html | 16 ++ .../meter-upload-modal.component.ts | 71 +++++++ .../property-taxlot-upload.component.html | 87 -------- .../property-taxlot-upload.component.ts | 200 ------------------ .../datasets/dataset/dataset.component.ts | 16 +- .../modules/datasets/datasets.component.ts | 20 +- 15 files changed, 444 insertions(+), 338 deletions(-) create mode 100644 src/@seed/components/modal/index.ts create mode 100644 src/@seed/components/modal/modal-header.component.html create mode 100644 src/@seed/components/modal/modal-header.component.ts create mode 100644 src/app/modules/datasets/data-upload/meter-upload-modal.component.html create mode 100644 src/app/modules/datasets/data-upload/meter-upload-modal.component.ts delete mode 100644 src/app/modules/datasets/data-upload/property-taxlot-upload.component.html delete mode 100644 src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 1134c480..55085397 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -1,17 +1,20 @@ // Subset type export type ImportFile = { + cached_first_row: string; + cached_second_to_fifth_row: string; created: string; - modified: string; - deleted: boolean; - import_record: number; cycle: number; cycle_name?: string; // used in dataset.component ag-grid + deleted: boolean; file: string; - uploaded_filename: string; - cached_first_row: string; id: number; - source_type: string; + import_record: number; + mapping_done: boolean; + matching_done: boolean; + modified: string; num_rows: number; + source_type: string; + uploaded_filename: string; } // Subset type diff --git a/src/@seed/components/delete-modal/delete-modal.component.html b/src/@seed/components/delete-modal/delete-modal.component.html index a833a84e..539f0cf8 100644 --- a/src/@seed/components/delete-modal/delete-modal.component.html +++ b/src/@seed/components/delete-modal/delete-modal.component.html @@ -1,10 +1,6 @@ -
- -
Delete {{ data.model }}
-
- + -
+
Are you sure you want to delete {{ data.model.toLowerCase() }} {{ data.instance }}? This action cannot be undone.
diff --git a/src/@seed/components/delete-modal/delete-modal.component.ts b/src/@seed/components/delete-modal/delete-modal.component.ts index f4322109..38bf043b 100644 --- a/src/@seed/components/delete-modal/delete-modal.component.ts +++ b/src/@seed/components/delete-modal/delete-modal.component.ts @@ -3,12 +3,13 @@ import { MatButtonModule } from '@angular/material/button' import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' +import { ModalHeaderComponent } from '../modal' @Component({ standalone: true, selector: 'seed-delete-modal', templateUrl: './delete-modal.component.html', - imports: [MatButtonModule, MatDialogModule, MatDividerModule, MatIconModule], + imports: [MatButtonModule, MatDialogModule, MatDividerModule, MatIconModule, ModalHeaderComponent], }) export class DeleteModalComponent { private _dialogRef = inject(MatDialogRef) diff --git a/src/@seed/components/index.ts b/src/@seed/components/index.ts index 6cebd7eb..394ce3d0 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 './modal' export * from './navigation' export * from './not-found' export * from './page' diff --git a/src/@seed/components/modal/index.ts b/src/@seed/components/modal/index.ts new file mode 100644 index 00000000..95b61406 --- /dev/null +++ b/src/@seed/components/modal/index.ts @@ -0,0 +1 @@ +export * from './modal-header.component' diff --git a/src/@seed/components/modal/modal-header.component.html b/src/@seed/components/modal/modal-header.component.html new file mode 100644 index 00000000..bcb9f1a7 --- /dev/null +++ b/src/@seed/components/modal/modal-header.component.html @@ -0,0 +1,22 @@ +
+
+
+ +
+ +
+
{{ title }}
+
+
+ + @if (close) { +
+ +
+ } +
+ + \ No newline at end of file diff --git a/src/@seed/components/modal/modal-header.component.ts b/src/@seed/components/modal/modal-header.component.ts new file mode 100644 index 00000000..07de1dd8 --- /dev/null +++ b/src/@seed/components/modal/modal-header.component.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common' +import { Component, Input } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' + +@Component({ + selector: 'seed-modal-header', + templateUrl: './modal-header.component.html', + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatDividerModule, + ], +}) +export class ModalHeaderComponent { + @Input() close: () => void + @Input() title: string + @Input() titleIcon: string +} diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.html b/src/app/modules/datasets/data-upload/data-upload-modal.component.html index 2fbf5148..6fd55479 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -1,27 +1,87 @@ -
-
-
- + + + + + +
+
+ +
+ + {{ form.get('multiCycle').value ? 'Default Cycle' : 'Cycle' }} + + + @for (cycle of cycles; track $index) { + {{ cycle.name }} + } + + + + + Multi-Cycle +
+ + + + + + +
+
+ + .csv, .xls, .xslx +
+
Note: only the first sheet of multi-sheet Excel files will be imported. +
+ +
+ + .geojson, .json +
+ +
+ + .xml +
+
+
-
-
Upload Property / Tax Lot Data
+ @if (uploading) { + + } @else { + +
+ } + + + + + @if (inProgress) { + + } + + + + +
+ +
-
-
- -
-
- - - + + \ No newline at end of file diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts index 59ab7586..f58967d7 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -1,29 +1,210 @@ +import type { StepperSelectionEvent } from '@angular/cdk/stepper' import { CommonModule } from '@angular/common' -import { Component, inject } from '@angular/core' +import type { HttpErrorResponse } from '@angular/common/http' +import type { AfterViewInit, ElementRef, OnDestroy} from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatCheckboxModule } from '@angular/material/checkbox' import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' +import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { MatSelectModule } from '@angular/material/select' +import { type MatStepper, MatStepperModule } from '@angular/material/stepper' +import { Router, RouterModule } from '@angular/router' +import { catchError, Subject, switchMap, takeUntil, tap } from 'rxjs' import type { Cycle } from '@seed/api/cycle' import type { Dataset } from '@seed/api/dataset' -import { PropertyTaxlotUploadComponent } from './property-taxlot-upload.component' +import type { OrganizationUserSettings } from '@seed/api/organization' +import { OrganizationService } from '@seed/api/organization' +import { UserService } from '@seed/api/user' +import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { ErrorService } from '@seed/services' +import type { ProgressBarObj } from '@seed/services/uploader' +import { UploaderService } from '@seed/services/uploader' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @Component({ selector: 'seed-data-upload-modal', templateUrl: './data-upload-modal.component.html', imports: [ CommonModule, + MatButtonModule, + MatCheckboxModule, MatDialogModule, MatDividerModule, + MatFormFieldModule, MatIconModule, - PropertyTaxlotUploadComponent, + MatInputModule, + MatProgressBarModule, + MatSelectModule, + MatStepperModule, + ModalHeaderComponent, + ProgressBarComponent, + ReactiveFormsModule, + RouterModule, ], }) -export class UploadFileModalComponent { - private _dialogRef = inject(MatDialogRef) +export class DataUploadModalComponent implements AfterViewInit, OnDestroy { + @ViewChild('stepper') stepper!: MatStepper + @ViewChild('fileInput') fileInput: ElementRef + + private _dialogRef = inject(MatDialogRef) + private _organizationService = inject(OrganizationService) + private _uploaderService = inject(UploaderService) + private _userService = inject(UserService) + private _errorService = inject(ErrorService) + private _router = inject(Router) + private _snackBar = inject(SnackBarService) + private readonly _unsubscribeAll$ = new Subject() + allowedTypes: string + completed = { 1: false, 2: false, 3: false } + cycles: Cycle[] + dataset: Dataset + file: File + fileId: number + inProgress = false + orgId: number + orgUserId: number + progressBarObj: ProgressBarObj = { + message: [], + progress: 0, + total: 100, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw' + uploading = false + userSettings: OrganizationUserSettings = {} data = inject(MAT_DIALOG_DATA) as { orgId: number; dataset: Dataset; cycles: Cycle[] } - dismiss() { + form = new FormGroup({ + cycleId: new FormControl(null, Validators.required), + multiCycle: new FormControl(false), + }) + + ngAfterViewInit() { + this.cycles = this.data.cycles + this.dataset = this.data.dataset + this.orgId = this.data.orgId + + this.form.patchValue({ cycleId: this.cycles[0]?.id }) + this._userService.currentUser$ + .pipe( + takeUntil(this._unsubscribeAll$), + tap((user) => { + this.orgUserId = user.org_user_id + this.userSettings = user.settings + }), + ) + .subscribe() + } + + step1(fileList: FileList) { + this.file = fileList?.[0] + const cycleId = this.form.get('cycleId')?.value + const multiCycle = this.form.get('multiCycle')?.value + this.uploading = true + this.userSettings.cycleId = cycleId + this._organizationService.updateOrganizationUser(this.orgUserId, this.orgId, this.userSettings).subscribe() + + return this._uploaderService + .fileUpload(this.orgId, this.file, this.sourceType, this.dataset.id.toString()) + .pipe( + takeUntil(this._unsubscribeAll$), + tap(({ import_file_id }) => { + this.fileId = import_file_id + this.completed[1] = true + }), + switchMap(() => this._uploaderService.saveRawData(this.orgId, cycleId, this.fileId, multiCycle)), + tap(({ progress_key }: { progress_key: string }) => { + this.uploading = false + this.stepper.next() + this.step2(progress_key) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error uploading file') + }), + ) + .subscribe() + } + + triggerUpload(sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw') { + this.sourceType = sourceType + const allowedMap = { + 'Assessed Raw': '.csv,.xls,.xlsx', + GeoJSON: '.geojson,application/geo+json', + 'BuildingSync Raw': '.xml,application/xml,text/xml', + } + this.allowedTypes = allowedMap[sourceType] + + setTimeout(() => { + this.fileInput.nativeElement.click() + }) + } + + step2(progressKey: string) { + this.inProgress = true + + const failureFn = () => { + this._snackBar.alert('File Upload Failed') + } + + const successFn = () => { + this.completed[2] = true + setTimeout(() => { + this.stepper.next() + }) + } + + this._uploaderService + .checkProgressLoop({ + progressKey, + offset: 0, + multiplier: 1, + failureFn, + successFn, + progressBarObj: this.progressBarObj, + }) + .subscribe() + } + + goToMapping() { + this.close() + void this._router.navigate(['/data/mappings', this.fileId]) + } + + goToStep1() { + this.completed[3] = true + this.stepper.selectedIndex = 0 + } + + onStepChange(event: StepperSelectionEvent) { + const index = event.selectedIndex + if (index === 0) this.resetStepper() + } + + resetStepper() { + this.completed = { 1: false, 2: false, 3: false } + this.file = null + this.fileId = null + this.fileInput.nativeElement.value = '' + this.inProgress = false + this.uploading = false + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + close = () => { this._dialogRef.close() } } diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html new file mode 100644 index 00000000..528e6450 --- /dev/null +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -0,0 +1,16 @@ + + + + + STEP 1 + + + STEP 2 + + + STEP 3 + + + STEP 4 + + \ No newline at end of file diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts new file mode 100644 index 00000000..d2992537 --- /dev/null +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -0,0 +1,71 @@ +import { CommonModule } from '@angular/common' +import type { AfterViewInit, OnDestroy } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import type { MatStepper } from '@angular/material/stepper'; +import { MatStepperModule } from '@angular/material/stepper' +import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { ConfigService } from '@seed/services' +import { UploaderService } from '@seed/services/uploader/uploader.service' +import type { ProgressBarObj } from '@seed/services/uploader/uploader.types' +import { AgGridAngular } from 'ag-grid-angular' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { Subject } from 'rxjs' + +@Component({ + selector: 'seed-meter-data-upload-modal', + templateUrl: './meter-upload-modal.component.html', + imports: [ + AgGridAngular, + CommonModule, + MatButtonModule, + MatDialogModule, + MatDividerModule, + MatIconModule, + MatProgressBarModule, + ModalHeaderComponent, + MatStepperModule, + ProgressBarComponent, + ], +}) +export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { + @ViewChild('stepper') stepper!: MatStepper + + private _dialogRef = inject(MatDialogRef) + private _configService = inject(ConfigService) + private _uploaderService = inject(UploaderService) + private _snackBar = inject(SnackBarService) + private readonly _unsubscribeAll$ = new Subject() + readonly allowedTypes = [ + 'application/vnd.ms-excel', // .xls + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx + 'text/csv', + 'text/plain', + ] + file?: File + fileId: number + completed = { 1: false, 2: false, 3: false, 4: false } + inProgress = false + uploading = false + gridTheme$ = this._configService.gridTheme$ + progressBarObj: ProgressBarObj = this._uploaderService.defaultProgressBarObj + + data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number } + + ngAfterViewInit() { + console.log('init') + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + close = () => { + this._dialogRef.close() + } +} diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html deleted file mode 100644 index a8d1677b..00000000 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.html +++ /dev/null @@ -1,87 +0,0 @@ - - - -
-
- -
- - {{ form.get('multiCycle').value ? 'Default Cycle' : 'Cycle' }} - - - @for (cycle of cycles; track $index) { - {{ cycle.name }} - } - - - - - Multi-Cycle -
- - - - - - -
-
- - .csv, .xls, .xslx -
-
Note: only the first sheet of multi-sheet Excel files will be imported.
- -
- - .geojson, .json -
- -
- - .xml -
-
-
-
- - @if (uploading) { - - } @else { - -
- } -
- - - - @if (inProgress) { - - } - - - - -
- - -
-
-
diff --git a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts deleted file mode 100644 index ce060824..00000000 --- a/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { StepperSelectionEvent } from '@angular/cdk/stepper' -import { CommonModule } from '@angular/common' -import type { HttpErrorResponse } from '@angular/common/http' -import type { AfterViewInit, ElementRef, OnDestroy } from '@angular/core' -import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core' -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' -import { MatButtonModule } from '@angular/material/button' -import { MatCheckboxModule } from '@angular/material/checkbox' -import { MatDialogModule } from '@angular/material/dialog' -import { MatDividerModule } from '@angular/material/divider' -import { MatFormFieldModule } from '@angular/material/form-field' -import { MatIconModule } from '@angular/material/icon' -import { MatInputModule } from '@angular/material/input' -import { MatProgressBarModule } from '@angular/material/progress-bar' -import { MatSelectModule } from '@angular/material/select' -import type { MatStepper } from '@angular/material/stepper' -import { MatStepperModule } from '@angular/material/stepper' -import { Router, RouterModule } from '@angular/router' -import { catchError, Subject, switchMap, takeUntil, tap } from 'rxjs' -import type { Cycle } from '@seed/api/cycle' -import type { Dataset } from '@seed/api/dataset' -import type { OrganizationUserSettings } from '@seed/api/organization' -import { OrganizationService } from '@seed/api/organization' -import { UserService } from '@seed/api/user' -import { ProgressBarComponent } from '@seed/components' -import { ErrorService } from '@seed/services' -import type { ProgressBarObj } from '@seed/services/uploader' -import { UploaderService } from '@seed/services/uploader' -import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' - -@Component({ - selector: 'seed-property-taxlot-upload', - templateUrl: './property-taxlot-upload.component.html', - imports: [ - CommonModule, - MatButtonModule, - MatCheckboxModule, - MatDialogModule, - MatDividerModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - MatProgressBarModule, - MatSelectModule, - MatStepperModule, - ProgressBarComponent, - ReactiveFormsModule, - RouterModule, - ], -}) -export class PropertyTaxlotUploadComponent implements AfterViewInit, OnDestroy { - @ViewChild('stepper') stepper!: MatStepper - @ViewChild('fileInput') fileInput: ElementRef - @Input() cycles: Cycle[] - @Input() dataset: Dataset - @Input() orgId: number - @Output() dismissModal = new EventEmitter() - - private _organizationService = inject(OrganizationService) - private _uploaderService = inject(UploaderService) - private _userService = inject(UserService) - private _errorService = inject(ErrorService) - private _router = inject(Router) - private _snackBar = inject(SnackBarService) - private readonly _unsubscribeAll$ = new Subject() - allowedTypes: string - completed = { 1: false, 2: false, 3: false } - file: File - fileId: number - inProgress = false - orgUserId: number - progressBarObj: ProgressBarObj = { - message: [], - progress: 0, - total: 100, - complete: false, - statusMessage: '', - progressLastUpdated: null, - progressLastChecked: null, - } - sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw' - uploading = false - userSettings: OrganizationUserSettings = {} - - form = new FormGroup({ - cycleId: new FormControl(null, Validators.required), - multiCycle: new FormControl(false), - }) - - ngAfterViewInit() { - this.form.patchValue({ cycleId: this.cycles[0]?.id }) - this._userService.currentUser$ - .pipe( - takeUntil(this._unsubscribeAll$), - tap((user) => { - this.orgUserId = user.org_user_id - this.userSettings = user.settings - }), - ) - .subscribe() - } - - step1(fileList: FileList) { - this.file = fileList?.[0] - const cycleId = this.form.get('cycleId')?.value - const multiCycle = this.form.get('multiCycle')?.value - this.uploading = true - this.userSettings.cycleId = cycleId - this._organizationService.updateOrganizationUser(this.orgUserId, this.orgId, this.userSettings).subscribe() - - return this._uploaderService - .fileUpload(this.orgId, this.file, this.sourceType, this.dataset.id.toString()) - .pipe( - takeUntil(this._unsubscribeAll$), - tap(({ import_file_id }) => { - this.fileId = import_file_id - this.completed[1] = true - }), - switchMap(() => this._uploaderService.saveRawData(this.orgId, cycleId, this.fileId, multiCycle)), - tap(({ progress_key }: { progress_key: string }) => { - this.uploading = false - this.stepper.next() - this.step2(progress_key) - }), - catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, 'Error uploading file') - }), - ) - .subscribe() - } - - triggerUpload(sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw') { - this.sourceType = sourceType - const allowedMap = { - 'Assessed Raw': '.csv,.xls,.xlsx', - GeoJSON: '.geojson,application/geo+json', - 'BuildingSync Raw': '.xml,application/xml,text/xml', - } - this.allowedTypes = allowedMap[sourceType] - - setTimeout(() => { - this.fileInput.nativeElement.click() - }) - } - - step2(progressKey: string) { - this.inProgress = true - - const failureFn = () => { - this._snackBar.alert('File Upload Failed') - } - - const successFn = () => { - this.completed[2] = true - setTimeout(() => { - this.stepper.next() - }) - } - - this._uploaderService - .checkProgressLoop({ - progressKey, - offset: 0, - multiplier: 1, - failureFn, - successFn, - progressBarObj: this.progressBarObj, - }) - .subscribe() - } - - goToMapping() { - this.dismissModal.emit() - void this._router.navigate(['/data/mappings', this.fileId]) - } - - goToStep1() { - this.completed[3] = true - this.stepper.selectedIndex = 0 - } - - onStepChange(event: StepperSelectionEvent) { - const index = event.selectedIndex - if (index === 0) this.resetStepper() - } - - resetStepper() { - this.completed = { 1: false, 2: false, 3: false } - this.file = null - this.fileId = null - this.fileInput.nativeElement.value = '' - this.inProgress = false - this.uploading = false - } - - ngOnDestroy(): void { - this._unsubscribeAll$.next() - this._unsubscribeAll$.complete() - } -} diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 902f6904..2f2373ef 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -91,14 +91,20 @@ export class DatasetComponent implements OnDestroy, OnInit { ] } - actionsRenderer() { + actionsRenderer({ data }: { data: ImportFile }) { + const enableMapping = !!(data.num_rows && data.cached_second_to_fifth_row) + const enablePairing = !!(data.num_rows && data.mapping_done && data.matching_done && data.cycle_name !== undefined) + + const mappingSpan = `` + const pairingSpan = `` + return `
- + ${mappingSpan} Data Mapping open_in_new - + ${pairingSpan} Data Pairing open_in_new @@ -108,6 +114,10 @@ export class DatasetComponent implements OnDestroy, OnInit { ` } + enableDataMapping(file: ImportFile) { + return !file.num_rows || !file.cached_second_to_fifth_row + } + onGridReady(agGrid: GridReadyEvent) { this.gridApi = agGrid.api this.gridApi.sizeColumnsToFit() diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index 451136cf..839b3e65 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -15,7 +15,8 @@ import { UserService } from '@seed/api/user' import { DeleteModalComponent, PageComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { naturalSort } from '@seed/utils' -import { UploadFileModalComponent } from './data-upload/data-upload-modal.component' +import { DataUploadModalComponent } from './data-upload/data-upload-modal.component' +import { MeterDataUploadModalComponent } from './data-upload/meter-upload-modal.component' import { FormModalComponent } from './modal/form-modal.component' @Component({ @@ -79,10 +80,10 @@ export class DatasetsComponent implements OnDestroy, OnInit { this.columnDefs = [ { field: 'id', hide: true }, { field: 'name', headerName: 'Name', cellRenderer: this.nameRenderer }, - { field: 'importfiles', headerName: 'Files', valueGetter: ({ data }: { data: Dataset }) => data.importfiles.length }, - { field: 'updated_at', headerName: 'Updated At', valueGetter: ({ data }: { data: Dataset }) => new Date(data.updated_at).toLocaleDateString() }, + { field: 'importfiles', headerName: 'Files', flex: 0.5, valueGetter: ({ data }: { data: Dataset }) => data.importfiles.length }, + { field: 'updated_at', headerName: 'Updated At', flex: 0.5, valueGetter: ({ data }: { data: Dataset }) => new Date(data.updated_at).toLocaleDateString() }, { field: 'last_modified_by', headerName: 'Last Modified By' }, - { field: 'actions', headerName: 'Actions', cellRenderer: this.actionsRenderer }, + { field: 'actions', headerName: 'Actions', cellRenderer: this.actionsRenderer, flex: 1 }, ] } @@ -102,6 +103,10 @@ export class DatasetsComponent implements OnDestroy, OnInit { add Data Files + + add + Meter Data + edit clear
@@ -123,10 +128,15 @@ export class DatasetsComponent implements OnDestroy, OnInit { const dataset = this.datasets.find((ds) => ds.id === id) if (action === 'addDataFiles') { - this._dialog.open(UploadFileModalComponent, { + this._dialog.open(DataUploadModalComponent, { width: '40rem', data: { orgId: this.orgId, dataset, cycles: this.cycles }, }) + } else if (action === 'addMeterData') { + this._dialog.open(MeterDataUploadModalComponent, { + width: '50rem', + data: { orgId: this.orgId, dataset }, + }) } else if (action === 'rename') { this.editDataset(dataset) } else if (action === 'delete') { From 0625e51fee7a27fb221accc0f8d3c4104a054029 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 11 Jul 2025 14:25:44 +0000 Subject: [PATCH 39/50] meter preview --- cspell.json | 1 + .../services/uploader/uploader.service.ts | 10 +++++ src/@seed/services/uploader/uploader.types.ts | 7 +++ .../meter-upload-modal.component.html | 35 ++++++++++++++- .../meter-upload-modal.component.ts | 45 ++++++++++++++++++- .../modules/datasets/datasets.component.ts | 2 +- .../datasets/modal/form-modal.component.html | 9 ++-- .../datasets/modal/form-modal.component.ts | 2 + 8 files changed, 102 insertions(+), 9 deletions(-) diff --git a/cspell.json b/cspell.json index 27817448..ab684b69 100644 --- a/cspell.json +++ b/cspell.json @@ -32,6 +32,7 @@ "SRID", "Syncr", "ubids", + "unlinkable", "unpair", "Unpair", "unpairing", diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index 2c106d94..9d7a71e4 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.service.ts @@ -8,6 +8,7 @@ import { ErrorService } from '../error' import type { CheckProgressLoopParams, GreenButtonMeterPreview, + MeterPreviewResponse, ProgressBarObj, SensorPreviewResponse, SensorReadingPreview, @@ -156,6 +157,15 @@ export class UploaderService { ) } + metersPreview(orgId: number, fileId: number): Observable { + const url = `/api/v3/import_files/${fileId}/pm_meters_preview/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching meters preview') + }), + ) + } + saveRawData(orgId: number, cycleId: number, fileId: number, multipleCycleUpload = false): Observable { const url = `/api/v3/import_files/${fileId}/start_save_data/?organization_id=${orgId}` const body = { cycle_id: cycleId, multiple_cycle_upload: multipleCycleUpload } diff --git a/src/@seed/services/uploader/uploader.types.ts b/src/@seed/services/uploader/uploader.types.ts index 93dbf45a..04b3ede5 100644 --- a/src/@seed/services/uploader/uploader.types.ts +++ b/src/@seed/services/uploader/uploader.types.ts @@ -48,6 +48,7 @@ export type GreenButtonMeterPreview = { } export type ProposedMeterImport = { + cycles?: string; incoming: number; property_id: number; source_id: string; @@ -60,3 +61,9 @@ export type ValidatedTypeUnit = { parsed_type: string; parsed_unit: string; } + +export type MeterPreviewResponse = { + proposed_imports: ProposedMeterImport[]; + unlinkable_pm_ids: number[]; + validated_type_units: ValidatedTypeUnit[]; +} diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html index 528e6450..15f2ac15 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -1,8 +1,41 @@ + - STEP 1 +
+
Accepted File Types: .csv, .xls, .xlsx
+ +
+ + + +
+ {{ file?.name ?? 'No file selected' }} +
+
+ +
Note: only the first sheet of multi-sheet Excel files will be imported.
+ +
+ + @if (uploading) { + + } @else { +
+ +
+ }
STEP 2 diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index d2992537..e552fab5 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -11,10 +11,10 @@ import { MatStepperModule } from '@angular/material/stepper' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { UploaderService } from '@seed/services/uploader/uploader.service' -import type { ProgressBarObj } from '@seed/services/uploader/uploader.types' +import type { ProgressBarObj, ProposedMeterImport } from '@seed/services/uploader/uploader.types' import { AgGridAngular } from 'ag-grid-angular' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import { Subject } from 'rxjs' +import { Subject, switchMap, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-meter-data-upload-modal', @@ -52,7 +52,9 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { inProgress = false uploading = false gridTheme$ = this._configService.gridTheme$ + gridHeight = 0 progressBarObj: ProgressBarObj = this._uploaderService.defaultProgressBarObj + proposedImports: ProposedMeterImport[] = [] data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number } @@ -60,6 +62,45 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { console.log('init') } + step1(fileList: FileList) { + if (fileList.length !== 1) return + const { orgId, datasetId } = this.data + const [file] = fileList + this.file = file + const sourceType = 'PM Meter Usage' + this.uploading = true + + return this._uploaderService + .fileUpload(orgId, this.file, sourceType, datasetId) + .pipe( + takeUntil(this._unsubscribeAll$), + tap((response) => { console.log(response)}), + tap(({ import_file_id }) => { + this.fileId = import_file_id + this.completed[1] = true + }), + switchMap(() => this._uploaderService.metersPreview(orgId, this.fileId)), + tap(({ proposed_imports }) => { + this.proposedImports = proposed_imports + this.gridHeight = Math.min(this.proposedImports.length * 35 + 42, 300) + this.stepper.next() + this.validateImports() + this.uploading = false + }), + ) + .subscribe() + } + + validateImports() { + console.log('task: Validate imports...') + // this.validFile = this.proposedImports.every((item) => item?.column_name) + // this.completed[2] = this.validFile + // if (!this.validFile) { + // this._snackBar.alert('Invalid file format.') + // } + } + + ngOnDestroy() { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index 839b3e65..a5e055e8 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -135,7 +135,7 @@ export class DatasetsComponent implements OnDestroy, OnInit { } else if (action === 'addMeterData') { this._dialog.open(MeterDataUploadModalComponent, { width: '50rem', - data: { orgId: this.orgId, dataset }, + data: { orgId: this.orgId, datasetId: dataset.id }, }) } else if (action === 'rename') { this.editDataset(dataset) diff --git a/src/app/modules/datasets/modal/form-modal.component.html b/src/app/modules/datasets/modal/form-modal.component.html index 36b4eaa5..b9fd7764 100644 --- a/src/app/modules/datasets/modal/form-modal.component.html +++ b/src/app/modules/datasets/modal/form-modal.component.html @@ -1,8 +1,7 @@ -
- -
{{ create ? 'Create' : 'Edit' }} Dataset
-
- +
diff --git a/src/app/modules/datasets/modal/form-modal.component.ts b/src/app/modules/datasets/modal/form-modal.component.ts index 875d7d1a..ec4be62f 100644 --- a/src/app/modules/datasets/modal/form-modal.component.ts +++ b/src/app/modules/datasets/modal/form-modal.component.ts @@ -10,6 +10,7 @@ import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import type { Dataset } from '@seed/api/dataset' import { DatasetService } from '@seed/api/dataset' +import { ModalHeaderComponent } from '@seed/components' import { SEEDValidators } from '@seed/validators' @Component({ @@ -23,6 +24,7 @@ import { SEEDValidators } from '@seed/validators' MatFormFieldModule, MatIconModule, MatInputModule, + ModalHeaderComponent, ReactiveFormsModule, ], }) From 4e79c1bed5f5f12a55fcd7206243e16a0236a43a Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 11 Jul 2025 15:50:29 +0000 Subject: [PATCH 40/50] step2 grid --- src/@seed/services/uploader/uploader.types.ts | 3 +- src/@seed/utils/csv-download.ts | 17 +++++ src/@seed/utils/index.ts | 1 + .../meter-upload-modal.component.html | 48 +++++++++++++- .../meter-upload-modal.component.ts | 63 ++++++++++++++----- .../modules/datasets/datasets.component.ts | 2 +- .../list/inventory.component.ts | 2 +- 7 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 src/@seed/utils/csv-download.ts diff --git a/src/@seed/services/uploader/uploader.types.ts b/src/@seed/services/uploader/uploader.types.ts index 04b3ede5..f9d532e6 100644 --- a/src/@seed/services/uploader/uploader.types.ts +++ b/src/@seed/services/uploader/uploader.types.ts @@ -51,8 +51,9 @@ export type ProposedMeterImport = { cycles?: string; incoming: number; property_id: number; + pm_property_id?: number; source_id: string; - systemId: number; + system_id: number; type: string; successfully_imported?: number; } diff --git a/src/@seed/utils/csv-download.ts b/src/@seed/utils/csv-download.ts new file mode 100644 index 00000000..9f456c95 --- /dev/null +++ b/src/@seed/utils/csv-download.ts @@ -0,0 +1,17 @@ +export const csvDownload = (title: string, data: Record[]) => { + const headers = Object.keys(data[0]) + const csv = [ + headers.join(','), + ...data.map((row) => headers.map((header) => JSON.stringify(row[header] ?? '')).join(',')), + ].join('\n') + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + + const a = document.createElement('a') + a.href = url + a.download = `${title}.csv` + a.click() + + URL.revokeObjectURL(url) +} diff --git a/src/@seed/utils/index.ts b/src/@seed/utils/index.ts index d61f3e0b..3afa5e2b 100644 --- a/src/@seed/utils/index.ts +++ b/src/@seed/utils/index.ts @@ -1,4 +1,5 @@ export * from './arrays-equal' +export * from './csv-download' export * from './exact-match-options' export * from './natural-sort' export * from './open-in-new-tab' diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html index 15f2ac15..a74ff6f5 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -30,19 +30,63 @@
@if (uploading) { - + } @else {
} + - STEP 2 + +
+
+ + {{ incomingTitle }} +
+ + +
+ + + +
+
+ + Parsed Energy Types and Units +
+ + +
+ + +
+ + @if (completed[2]) { + + } +
+ STEP 3 + STEP 4 diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index e552fab5..e85af9af 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -8,13 +8,15 @@ import { MatIconModule } from '@angular/material/icon' import { MatProgressBarModule } from '@angular/material/progress-bar' import type { MatStepper } from '@angular/material/stepper'; import { MatStepperModule } from '@angular/material/stepper' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { catchError, EMPTY, Subject, switchMap, takeUntil, tap } from 'rxjs' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { UploaderService } from '@seed/services/uploader/uploader.service' -import type { ProgressBarObj, ProposedMeterImport } from '@seed/services/uploader/uploader.types' -import { AgGridAngular } from 'ag-grid-angular' +import type { ProgressBarObj, ProposedMeterImport, ValidatedTypeUnit } from '@seed/services/uploader/uploader.types' +import { csvDownload } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import { Subject, switchMap, takeUntil, tap } from 'rxjs' @Component({ selector: 'seed-meter-data-upload-modal', @@ -50,11 +52,27 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { fileId: number completed = { 1: false, 2: false, 3: false, 4: false } inProgress = false + incomingTitle: string uploading = false gridTheme$ = this._configService.gridTheme$ - gridHeight = 0 progressBarObj: ProgressBarObj = this._uploaderService.defaultProgressBarObj proposedImports: ProposedMeterImport[] = [] + readingHeight = 0 + step1ProgressTitle = 'Uploading file...' + unitHeight = 0 + validatedTypeUnits: ValidatedTypeUnit[] = [] + validFile = false + readingDefs: ColDef[] = [ + { field: 'pm_property_id', headerName: 'PM Property ID' }, + { field: 'cycles', headerName: 'Cycles' }, + { field: 'source_id', headerName: 'PM Meter ID' }, + { field: 'type', headerName: 'Type' }, + { field: 'incoming', headerName: 'Incoming' }, + ] + unitDefs: ColDef[] = [ + { field: 'parsed_type', headerName: 'Type', flex: 1 }, + { field: 'parsed_unit', headerName: 'Unit', flex: 1 }, + ] data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number } @@ -74,32 +92,45 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { .fileUpload(orgId, this.file, sourceType, datasetId) .pipe( takeUntil(this._unsubscribeAll$), - tap((response) => { console.log(response)}), tap(({ import_file_id }) => { this.fileId = import_file_id this.completed[1] = true + this.step1ProgressTitle = 'Analyzing file...' }), switchMap(() => this._uploaderService.metersPreview(orgId, this.fileId)), - tap(({ proposed_imports }) => { - this.proposedImports = proposed_imports - this.gridHeight = Math.min(this.proposedImports.length * 35 + 42, 300) + tap((response) => { + this.setIncomingTitle(response) + this.proposedImports = response.proposed_imports + this.validatedTypeUnits = response.validated_type_units + this.readingHeight = Math.min(this.proposedImports.length * 35 + 42, 250) + this.unitHeight = Math.min(this.validatedTypeUnits.length * 35 + 42, 200) this.stepper.next() - this.validateImports() + this.completed[2] = true this.uploading = false }), + catchError(() => { + this.uploading = false + return EMPTY + }), ) .subscribe() } - validateImports() { - console.log('task: Validate imports...') - // this.validFile = this.proposedImports.every((item) => item?.column_name) - // this.completed[2] = this.validFile - // if (!this.validFile) { - // this._snackBar.alert('Invalid file format.') - // } + step3() { + console.log('step3') + } + + setIncomingTitle(response: { proposed_imports: ProposedMeterImport[] }) { + const { proposed_imports } = response + const meterCount = proposed_imports.length + const propertyCount = new Set(proposed_imports.map((m) => m.pm_property_id)).size + this.incomingTitle = `${meterCount > 1 ? `${meterCount} Meters` : '1 Meter'} from ${propertyCount > 1 ? `${propertyCount} Properties` : '1 Property'}` } + csvDownload(title: 'proposed_meter_imports' | 'validated_type_units') { + const data = title === 'proposed_meter_imports' ? this.proposedImports : this.validatedTypeUnits + csvDownload(title, data) + } ngOnDestroy() { this._unsubscribeAll$.next() diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index a5e055e8..a30f6e40 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -134,7 +134,7 @@ export class DatasetsComponent implements OnDestroy, OnInit { }) } else if (action === 'addMeterData') { this._dialog.open(MeterDataUploadModalComponent, { - width: '50rem', + width: '60rem', data: { orgId: this.orgId, datasetId: dataset.id }, }) } else if (action === 'rename') { diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 5cedc297..f9fea980 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -180,7 +180,7 @@ export class InventoryComponent implements OnDestroy, OnInit { this.cycles = cycles this.cycle = this.cycles.find((c) => c.id === this.userSettings?.cycleId) ?? this.cycles[0] - this.cycleId = this.cycle.id + this.cycleId = this.cycle?.id this.propertyProfiles = profiles.filter((p) => p.inventory_type === 0) this.taxlotProfiles = profiles.filter((p) => p.inventory_type === 1) From 38bab8005a4d8dd026cf5d4328997ca53207db10 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 11 Jul 2025 17:06:20 +0000 Subject: [PATCH 41/50] progress loop default vals --- .../services/uploader/uploader.service.ts | 12 ++++----- src/@seed/services/uploader/uploader.types.ts | 8 +++--- .../data-mappings/data-mapping.component.ts | 2 -- .../step3/save-mappings.component.ts | 5 ---- .../step4/match-merge.component.ts | 6 ----- .../meter-upload-modal.component.html | 8 +++++- .../meter-upload-modal.component.ts | 27 +++++++++++++++++-- .../modules/datasets/datasets.component.ts | 6 ++--- .../green-button-upload-modal.component.ts | 2 -- .../modal/sensor-readings-upload.component.ts | 2 -- .../modal/sensors-upload.component.ts | 2 -- .../modal/more-actions-modal.component.ts | 4 --- .../list/modal/delete-modal.component.ts | 2 -- .../list/modal/form-modal.component.ts | 2 -- .../modal/confirm-modal.component.ts | 2 -- .../columns/modal/update-modal.component.ts | 2 -- .../cycles/modal/delete-modal.component.ts | 2 -- 17 files changed, 45 insertions(+), 49 deletions(-) diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index 9d7a71e4..d82e091f 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.service.ts @@ -38,10 +38,10 @@ export class UploaderService { */ checkProgressLoop({ progressKey, - offset, - multiplier, - successFn, - failureFn, + offset = 0, + multiplier = 1, + successFn = () => null, + failureFn = () => null, progressBarObj, subProgress = false, }: CheckProgressLoopParams): Observable { @@ -166,10 +166,10 @@ export class UploaderService { ) } - saveRawData(orgId: number, cycleId: number, fileId: number, multipleCycleUpload = false): Observable { + saveRawData(orgId: number, cycleId: number, fileId: number, multipleCycleUpload = false): Observable { const url = `/api/v3/import_files/${fileId}/start_save_data/?organization_id=${orgId}` const body = { cycle_id: cycleId, multiple_cycle_upload: multipleCycleUpload } - return this._httpClient.post(url, body).pipe( + return this._httpClient.post(url, body).pipe( catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error saving raw data') }), diff --git a/src/@seed/services/uploader/uploader.types.ts b/src/@seed/services/uploader/uploader.types.ts index f9d532e6..67ba78b7 100644 --- a/src/@seed/services/uploader/uploader.types.ts +++ b/src/@seed/services/uploader/uploader.types.ts @@ -15,10 +15,10 @@ export type ProgressBarObj = { export type CheckProgressLoopParams = { progressKey: string; - offset: number; - multiplier: number; - successFn: () => void; - failureFn: () => void; + offset?: number; + multiplier?: number; + successFn?: () => void; + failureFn?: () => void; progressBarObj: ProgressBarObj; subProgress?: boolean; } diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index e9f62ae4..61a3983c 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -212,8 +212,6 @@ export class DataMappingComponent implements OnDestroy, OnInit { return this._uploaderService.checkProgressLoop({ progressKey: data.progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts index 0df05ba1..84daea27 100644 --- a/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -74,8 +74,6 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { const successFn = () => { this.dqcComplete = true } - // eslint-disable-next-line @typescript-eslint/no-empty-function - const failureFn = () => {} this._dataQualityService.startDataQualityCheckForImportFile(this.orgId, this.importFile.id) .pipe( @@ -83,10 +81,7 @@ export class SaveMappingsComponent implements OnChanges, OnDestroy { switchMap(({ progress_key }) => { return this._uploaderService.checkProgressLoop({ progressKey: progress_key, - offset: 0, - multiplier: 1, successFn, - failureFn, progressBarObj: this.progressBarObj, }) }), diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index b7a3c6cd..0c97a75e 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -62,21 +62,15 @@ export class MatchMergeComponent implements OnDestroy { } const { progress_data, sub_progress_data } = data - const baseParams = { offset: 0, multiplier: 1 } const mainParams: CheckProgressLoopParams = { progressKey: progress_data.progress_key, successFn, - failureFn: () => void 0, progressBarObj: this.progressBarObj, - ...baseParams, } const subParams: CheckProgressLoopParams = { progressKey: sub_progress_data.progress_key, - successFn: () => void 0, - failureFn: () => void 0, progressBarObj: this.subProgressBarObj, - ...baseParams, } return this._uploaderService.checkProgressLoopMainSub(mainParams, subParams) diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html index a74ff6f5..f611bd2f 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -84,7 +84,13 @@ - STEP 3 + @if (inProgress) { + + } diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index e85af9af..8eddfe77 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -50,6 +50,7 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { ] file?: File fileId: number + cycleId: number completed = { 1: false, 2: false, 3: false, 4: false } inProgress = false incomingTitle: string @@ -74,7 +75,7 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { { field: 'parsed_unit', headerName: 'Unit', flex: 1 }, ] - data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number } + data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number; cycleId: number } ngAfterViewInit() { console.log('init') @@ -117,7 +118,29 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { } step3() { - console.log('step3') + this.stepper.next() + this.inProgress = true + const { orgId, cycleId } = this.data + + const successFn = () => { + this.completed[3] = true + setTimeout(() => { + this.stepper.next() + }) + } + + const failureFn = () => this.inProgress = false + + this._uploaderService.saveRawData(orgId, cycleId, this.fileId) + .pipe( + switchMap(({ progress_key }) => this._uploaderService.checkProgressLoop({ + progressKey: progress_key, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + })), + ) + .subscribe() } setIncomingTitle(response: { proposed_imports: ProposedMeterImport[] }) { diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index a30f6e40..a8347766 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -127,15 +127,15 @@ export class DatasetsComponent implements OnDestroy, OnInit { const { id } = event.data as { id: number } const dataset = this.datasets.find((ds) => ds.id === id) - if (action === 'addDataFiles') { + if (action === 'addDataFiles' && this.cycles.length) { this._dialog.open(DataUploadModalComponent, { width: '40rem', data: { orgId: this.orgId, dataset, cycles: this.cycles }, }) - } else if (action === 'addMeterData') { + } else if (action === 'addMeterData' && this.cycles.length) { this._dialog.open(MeterDataUploadModalComponent, { width: '60rem', - data: { orgId: this.orgId, datasetId: dataset.id }, + data: { orgId: this.orgId, datasetId: dataset.id, cycleId: this.cycles[0].id }, }) } else if (action === 'rename') { this.editDataset(dataset) diff --git a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts index f17b2695..58a37eaa 100644 --- a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts +++ b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts @@ -158,8 +158,6 @@ export class GreenButtonUploadModalComponent implements OnDestroy { switchMap((data) => { return this._uploaderService.checkProgressLoop({ progressKey: data.progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensor-readings-upload.component.ts b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensor-readings-upload.component.ts index 241d927c..b123ab4e 100644 --- a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensor-readings-upload.component.ts +++ b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensor-readings-upload.component.ts @@ -151,8 +151,6 @@ export class SensorReadingsUploadModalComponent implements OnDestroy { switchMap((data) => { return this._uploaderService.checkProgressLoop({ progressKey: data.progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.ts b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.ts index 78632059..5ef3841f 100644 --- a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.ts +++ b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.ts @@ -153,8 +153,6 @@ export class SensorsUploadModalComponent implements OnDestroy { switchMap((data) => { return this._uploaderService.checkProgressLoop({ progressKey: data.progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts index e9606d07..60a8e275 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts @@ -76,10 +76,6 @@ export class MoreActionsModalComponent implements OnDestroy { switchMap(({ progress_key }) => { return this._uploaderService.checkProgressLoop({ progressKey: progress_key, - offset: 0, - multiplier: 1, - successFn: () => null, - failureFn: () => null, progressBarObj: this.progressBarObj, }) }), diff --git a/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts b/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts index 6caa9c91..fdfd415c 100644 --- a/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts +++ b/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts @@ -62,8 +62,6 @@ export class DeleteModalComponent implements OnDestroy { switchMap(({ progress_key }) => { return this._uploaderService.checkProgressLoop({ progressKey: progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/organizations/columns/list/modal/form-modal.component.ts b/src/app/modules/organizations/columns/list/modal/form-modal.component.ts index 8c90c029..8fd0668e 100644 --- a/src/app/modules/organizations/columns/list/modal/form-modal.component.ts +++ b/src/app/modules/organizations/columns/list/modal/form-modal.component.ts @@ -100,8 +100,6 @@ export class FormModalComponent implements OnDestroy, OnInit { switchMap(({ progress_key }) => { return this._uploaderService.checkProgressLoop({ progressKey: progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/organizations/columns/matching-criteria/modal/confirm-modal.component.ts b/src/app/modules/organizations/columns/matching-criteria/modal/confirm-modal.component.ts index 2a76a387..41351e95 100644 --- a/src/app/modules/organizations/columns/matching-criteria/modal/confirm-modal.component.ts +++ b/src/app/modules/organizations/columns/matching-criteria/modal/confirm-modal.component.ts @@ -69,8 +69,6 @@ export class ConfirmModalComponent implements OnDestroy { switchMap(({ progress_key }) => { return this._uploaderService.checkProgressLoop({ progressKey: progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/organizations/columns/modal/update-modal.component.ts b/src/app/modules/organizations/columns/modal/update-modal.component.ts index 7dbb2c82..80aea911 100644 --- a/src/app/modules/organizations/columns/modal/update-modal.component.ts +++ b/src/app/modules/organizations/columns/modal/update-modal.component.ts @@ -50,8 +50,6 @@ export class UpdateModalComponent implements OnDestroy, OnInit { this._uploaderService .checkProgressLoop({ progressKey: this.data.progressResponse.progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, diff --git a/src/app/modules/organizations/cycles/modal/delete-modal.component.ts b/src/app/modules/organizations/cycles/modal/delete-modal.component.ts index 7fdc8220..3e691d1f 100644 --- a/src/app/modules/organizations/cycles/modal/delete-modal.component.ts +++ b/src/app/modules/organizations/cycles/modal/delete-modal.component.ts @@ -61,8 +61,6 @@ export class DeleteModalComponent implements OnDestroy { switchMap(({ progress_key }) => { return this._uploaderService.checkProgressLoop({ progressKey: progress_key, - offset: 0, - multiplier: 1, successFn, failureFn, progressBarObj: this.progressBarObj, From ea8ae54b88dc0362f989bd98239c6c7e4aa8217d Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 11 Jul 2025 18:20:08 +0000 Subject: [PATCH 42/50] meter upload from data page --- src/@seed/services/uploader/uploader.types.ts | 7 +-- .../meter-upload-modal.component.html | 35 ++++++++++--- .../meter-upload-modal.component.ts | 50 +++++++++++-------- .../green-button-upload-modal.component.ts | 8 +-- .../modal/sensors-upload.component.html | 6 ++- 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/src/@seed/services/uploader/uploader.types.ts b/src/@seed/services/uploader/uploader.types.ts index 67ba78b7..f9d9f446 100644 --- a/src/@seed/services/uploader/uploader.types.ts +++ b/src/@seed/services/uploader/uploader.types.ts @@ -43,12 +43,13 @@ export type SensorPreviewResponse = { export type SensorReadingPreview = { column_name: string; exists: boolean; num_readings: number } export type GreenButtonMeterPreview = { - proposed_imports: ProposedMeterImport[]; + proposed_imports: MeterImport[]; validated_type_units: ValidatedTypeUnit[]; } -export type ProposedMeterImport = { +export type MeterImport = { cycles?: string; + errors?: string; incoming: number; property_id: number; pm_property_id?: number; @@ -64,7 +65,7 @@ export type ValidatedTypeUnit = { } export type MeterPreviewResponse = { - proposed_imports: ProposedMeterImport[]; + proposed_imports: MeterImport[]; unlinkable_pm_ids: number[]; validated_type_units: ValidatedTypeUnit[]; } diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html index f611bd2f..0b73690e 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -2,7 +2,7 @@ - +
Accepted File Types: .csv, .xls, .xlsx
@@ -16,7 +16,7 @@ (change)="step1(fileInput.files)" type="file" /> - @@ -40,12 +40,12 @@ } - +
- {{ incomingTitle }} + {{ readingGridTitle }}
@@ -83,17 +83,36 @@
- + @if (inProgress) { } - - STEP 4 + + +
+
+ + {{ readingGridTitle }} +
+ + +
+ + +
+ +
\ No newline at end of file diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index 8eddfe77..d3e5e905 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common' -import type { AfterViewInit, OnDestroy } from '@angular/core' +import type { OnDestroy } from '@angular/core' import { Component, inject, ViewChild } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' @@ -14,7 +14,7 @@ import { catchError, EMPTY, Subject, switchMap, takeUntil, tap } from 'rxjs' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { UploaderService } from '@seed/services/uploader/uploader.service' -import type { ProgressBarObj, ProposedMeterImport, ValidatedTypeUnit } from '@seed/services/uploader/uploader.types' +import type { MeterImport, ProgressBarObj, ValidatedTypeUnit } from '@seed/services/uploader/uploader.types' import { csvDownload } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @@ -34,7 +34,7 @@ import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' ProgressBarComponent, ], }) -export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { +export class MeterDataUploadModalComponent implements OnDestroy { @ViewChild('stepper') stepper!: MatStepper private _dialogRef = inject(MatDialogRef) @@ -51,14 +51,15 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { file?: File fileId: number cycleId: number - completed = { 1: false, 2: false, 3: false, 4: false } + completed = { 1: false, 2: false, 3: false } inProgress = false - incomingTitle: string + readingGridTitle: string uploading = false gridTheme$ = this._configService.gridTheme$ progressBarObj: ProgressBarObj = this._uploaderService.defaultProgressBarObj - proposedImports: ProposedMeterImport[] = [] + proposedImports: MeterImport[] = [] readingHeight = 0 + importedMeters: MeterImport[] = [] step1ProgressTitle = 'Uploading file...' unitHeight = 0 validatedTypeUnits: ValidatedTypeUnit[] = [] @@ -70,6 +71,11 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { { field: 'type', headerName: 'Type' }, { field: 'incoming', headerName: 'Incoming' }, ] + importedDefs: ColDef[] = [ + ...this.readingDefs, + { field: 'successfully_imported', headerName: 'Successfully Imported' }, + { field: 'errors', headerName: 'Errors' }, + ] unitDefs: ColDef[] = [ { field: 'parsed_type', headerName: 'Type', flex: 1 }, { field: 'parsed_unit', headerName: 'Unit', flex: 1 }, @@ -77,10 +83,6 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number; cycleId: number } - ngAfterViewInit() { - console.log('init') - } - step1(fileList: FileList) { if (fileList.length !== 1) return const { orgId, datasetId } = this.data @@ -100,9 +102,10 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { }), switchMap(() => this._uploaderService.metersPreview(orgId, this.fileId)), tap((response) => { - this.setIncomingTitle(response) - this.proposedImports = response.proposed_imports - this.validatedTypeUnits = response.validated_type_units + const { proposed_imports, validated_type_units } = response + this.setReadingTitle(proposed_imports) + this.proposedImports = proposed_imports + this.validatedTypeUnits = validated_type_units this.readingHeight = Math.min(this.proposedImports.length * 35 + 42, 250) this.unitHeight = Math.min(this.validatedTypeUnits.length * 35 + 42, 200) this.stepper.next() @@ -124,6 +127,8 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { const successFn = () => { this.completed[3] = true + this.importedMeters = this.progressBarObj.message as MeterImport[] + this.setReadingTitle(this.importedMeters) setTimeout(() => { this.stepper.next() }) @@ -143,16 +148,19 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { .subscribe() } - setIncomingTitle(response: { proposed_imports: ProposedMeterImport[] }) { - const { proposed_imports } = response - const meterCount = proposed_imports.length - const propertyCount = new Set(proposed_imports.map((m) => m.pm_property_id)).size - this.incomingTitle = `${meterCount > 1 ? `${meterCount} Meters` : '1 Meter'} from ${propertyCount > 1 ? `${propertyCount} Properties` : '1 Property'}` + setReadingTitle(meterImports: MeterImport[]) { + const meterCount = meterImports.length + const propertyCount = new Set(meterImports.map((m) => m.pm_property_id)).size + this.readingGridTitle = `${meterCount > 1 ? `${meterCount} Meters` : '1 Meter'} from ${propertyCount > 1 ? `${propertyCount} Properties` : '1 Property'}` } - csvDownload(title: 'proposed_meter_imports' | 'validated_type_units') { - const data = title === 'proposed_meter_imports' ? this.proposedImports : this.validatedTypeUnits - csvDownload(title, data) + csvDownload(title: 'proposed_meter_imports' | 'validated_type_units' | 'imported_meters') { + const data = { + proposed_meter_imports: this.proposedImports, + validated_type_units: this.validatedTypeUnits, + imported_meters: this.importedMeters, + } + csvDownload(title, data[title]) } ngOnDestroy() { diff --git a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts index 58a37eaa..75e73088 100644 --- a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts +++ b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts @@ -19,7 +19,7 @@ import { MeterService } from '@seed/api/meters' import type { ProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' import { ConfigService } from '@seed/services' -import type { ProgressBarObj, ProposedMeterImport, ValidatedTypeUnit } from '@seed/services/uploader' +import type { ProgressBarObj, MeterImport, ValidatedTypeUnit } from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @@ -56,9 +56,9 @@ export class GreenButtonUploadModalComponent implements OnDestroy { completed = { 1: false, 2: false } file: File fileId: number - proposedImports: ProposedMeterImport[] = [] + proposedImports: MeterImport[] = [] typeUnits: ValidatedTypeUnit[] = [] - importedData: ProposedMeterImport[] = [] + importedData: MeterImport[] = [] gridTheme$ = this._configService.gridTheme$ inProgress = false uploading = false @@ -142,7 +142,7 @@ export class GreenButtonUploadModalComponent implements OnDestroy { this._meterService.list(orgId, viewId) this._meterService.listReadings(orgId, viewId, interval, excludedIds) this._snackBar.success('Meter data uploaded successfully') - this.importedData = this.progressBarObj.message as ProposedMeterImport[] + this.importedData = this.progressBarObj.message as MeterImport[] setTimeout(() => { this.stepper.next() }) diff --git a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html index 35b38be5..17158699 100644 --- a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html +++ b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html @@ -87,7 +87,11 @@ @if (proposedImports.length) { - + }
From 02c4ae8dc08ef53a3d744e79a5857d6b9a1b8348 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 11 Jul 2025 19:39:25 +0000 Subject: [PATCH 43/50] meter upload fxnal --- src/@seed/api/dataset/dataset.service.ts | 10 +++++ src/@seed/api/dataset/dataset.types.ts | 3 +- .../services/uploader/uploader.service.ts | 8 +++- .../data-mappings/data-mapping.component.html | 8 +++- .../data-mappings/data-mapping.component.ts | 2 + .../step4/match-merge.component.html | 6 ++- .../step4/match-merge.component.ts | 4 +- .../step4/results.component.html | 10 +++++ .../data-mappings/step4/results.component.ts | 41 +++++++++++++++++-- .../data-upload-modal.component.ts | 1 + .../meter-upload-modal.component.ts | 25 +++++++++-- .../modules/datasets/datasets.component.ts | 2 +- 12 files changed, 107 insertions(+), 13 deletions(-) diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index 68b8538f..81fb5119 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -122,4 +122,14 @@ export class DatasetService { }), ) } + + checkMetersTabExists(orgId: number, fileId: number): Observable { + const url = `/api/v3/import_files/${fileId}/check_meters_tab_exists?organization_id=${orgId}` + return this._httpClient.get<{ status: string; data: boolean }>(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error checking meters tab existence') + }), + ) + } } diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 55085397..4dc2d397 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -5,6 +5,7 @@ export type ImportFile = { created: string; cycle: number; cycle_name?: string; // used in dataset.component ag-grid + dataset: Dataset; deleted: boolean; file: string; id: number; @@ -33,7 +34,7 @@ export type Dataset = { super_organization: number; id: number; model: 'data_importer.importrecord'; - importfiles: ImportFile[]; + importfiles?: ImportFile[]; } export type ListDatasetsResponse = { diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index d82e091f..b98d058b 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.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 { catchError, combineLatest, finalize, interval, of, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' +import { catchError, combineLatest, finalize, interval, of, ReplaySubject, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' import type { ProgressResponse } from '@seed/api/progress' import { ErrorService } from '../error' import type { @@ -20,6 +20,8 @@ import type { export class UploaderService { private _httpClient = inject(HttpClient) private _errorService = inject(ErrorService) + private _file = new ReplaySubject(1) + file$ = this._file.asObservable() get defaultProgressBarObj(): ProgressBarObj { return { @@ -33,6 +35,10 @@ export class UploaderService { } } + setFile(file: File) { + this._file.next(file) + } + /* * Checks a progress key for updates until it completes */ diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 4f57ef1a..913dba89 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -64,7 +64,13 @@ - + diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index 61a3983c..594357b2 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -80,6 +80,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { completed = { 1: false, 2: false, 3: false, 4: false } currentProfile: Profile cycle: Cycle + datasetId: number fileId = this._router.snapshot.params.id as number firstFiveRows: Record[] helpOpened = false @@ -121,6 +122,7 @@ export class DataMappingComponent implements OnDestroy, OnInit { take(1), tap((importFile) => { this.importFile = importFile + this.datasetId = importFile.dataset?.id this.columnMappingProfileTypes = importFile.source_type === 'BuildingSync Raw' ? ['BuildingSync Default', 'BuildingSync Custom'] : ['Normal'] }), catchError(() => { diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html index a4c07be5..d328e632 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.html +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -11,10 +11,12 @@ > } @else { }
diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index 0c97a75e..e69f414e 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -24,9 +24,11 @@ import { ResultsComponent } from './results.component' ], }) export class MatchMergeComponent implements OnDestroy { + @Input() datasetId: number + @Input() cycleId: number @Input() importFileId: number - @Input() orgId: number @Input() inventoryType: InventoryType + @Input() orgId: number private _mappingService = inject(MappingService) private _uploaderService = inject(UploaderService) diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index bf78791b..d2283567 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -7,6 +7,16 @@ + @if(showMeterButton) { + + } +
diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.ts b/src/app/modules/datasets/data-mappings/step4/results.component.ts index bf8f81af..7666b901 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/results.component.ts @@ -2,17 +2,21 @@ import { CommonModule } from '@angular/common' import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' import { Component, inject, Input } from '@angular/core' import { MatButtonModule } from '@angular/material/button' +import { MatDialog } from '@angular/material/dialog' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatProgressBarModule } from '@angular/material/progress-bar' import { RouterModule } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' -import { Subject, takeUntil, tap } from 'rxjs' +import { Subject, take, takeUntil, tap } from 'rxjs' +import { DatasetService } from '@seed/api/dataset' import type { MatchingResultsResponse } from '@seed/api/mapping' import { MappingService } from '@seed/api/mapping' import { ConfigService } from '@seed/services' +import { UploaderService } from '@seed/services/uploader' import type { InventoryType } from 'app/modules/inventory' +import { MeterDataUploadModalComponent } from '../../data-upload/meter-upload-modal.component' @Component({ selector: 'seed-match-merge-results', @@ -28,13 +32,18 @@ import type { InventoryType } from 'app/modules/inventory' ], }) export class ResultsComponent implements OnChanges, OnDestroy { - @Input() importFileId: number - @Input() orgId: number + @Input() cycleId: number + @Input() datasetId: number @Input() inventoryType: InventoryType + @Input() importFileId: number @Input() inProgress = true + @Input() orgId: number private _configService = inject(ConfigService) + private _datasetService = inject(DatasetService) + private _dialog = inject(MatDialog) private _mappingService = inject(MappingService) + private _uploaderService = inject(UploaderService) private readonly _unsubscribeAll$ = new Subject() gridTheme$ = this._configService.gridTheme$ @@ -49,6 +58,8 @@ export class ResultsComponent implements OnChanges, OnDestroy { taxlotData: Record[] = [] hasPropertyData = false hasTaxlotData = false + checkingMeterTab = true + showMeterButton = true ngOnChanges(changes: SimpleChanges): void { if (changes.inProgress.currentValue === false) { @@ -63,6 +74,16 @@ export class ResultsComponent implements OnChanges, OnDestroy { tap((results) => { this.setGrid(results) }), ) .subscribe() + + this._datasetService.checkMetersTabExists(this.orgId, this.importFileId) + .pipe( + take(1), + tap((hasData) => { + this.checkingMeterTab = false + this.showMeterButton = hasData + }), + ) + .subscribe() } setGrid(results: MatchingResultsResponse) { @@ -101,6 +122,20 @@ export class ResultsComponent implements OnChanges, OnDestroy { return str.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) } + importMeters() { + this.showMeterButton = false + this._uploaderService.file$ + .pipe( + take(1), + tap((file) => { + this._dialog.open(MeterDataUploadModalComponent, { + width: '60rem', + data: { orgId: this.orgId, datasetId: this.datasetId, cycleId: this.cycleId, file }, + }) + }), + ).subscribe() + } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts index f58967d7..b7b7d963 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -108,6 +108,7 @@ export class DataUploadModalComponent implements AfterViewInit, OnDestroy { step1(fileList: FileList) { this.file = fileList?.[0] + this._uploaderService.setFile(this.file) const cycleId = this.form.get('cycleId')?.value const multiCycle = this.form.get('multiCycle')?.value this.uploading = true diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index d3e5e905..e950db01 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common' -import type { OnDestroy } from '@angular/core' +import type { AfterViewInit, OnDestroy } from '@angular/core' import { Component, inject, ViewChild } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' @@ -34,7 +34,7 @@ import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' ProgressBarComponent, ], }) -export class MeterDataUploadModalComponent implements OnDestroy { +export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { @ViewChild('stepper') stepper!: MatStepper private _dialogRef = inject(MatDialogRef) @@ -81,7 +81,14 @@ export class MeterDataUploadModalComponent implements OnDestroy { { field: 'parsed_unit', headerName: 'Unit', flex: 1 }, ] - data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number; cycleId: number } + data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number; cycleId: number; file: File } + + ngAfterViewInit(): void { + // if a file is passed, start upload immediately (from the property upload stepper) + if (this.data.file) { + this.skipToStep1() + } + } step1(fileList: FileList) { if (fileList.length !== 1) return @@ -163,6 +170,18 @@ export class MeterDataUploadModalComponent implements OnDestroy { csvDownload(title, data[title]) } + skipToStep1() { + const fileList = this.createFileList(this.data.file) + this.step1(fileList) + } + + createFileList(file: File) { + const dt = new DataTransfer() + dt.items.add(file) + const fileList = dt.files + return fileList + } + ngOnDestroy() { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index a8347766..292eebeb 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -135,7 +135,7 @@ export class DatasetsComponent implements OnDestroy, OnInit { } else if (action === 'addMeterData' && this.cycles.length) { this._dialog.open(MeterDataUploadModalComponent, { width: '60rem', - data: { orgId: this.orgId, datasetId: dataset.id, cycleId: this.cycles[0].id }, + data: { orgId: this.orgId, datasetId: dataset.id, cycleId: this.cycles[0].id, file: null }, }) } else if (action === 'rename') { this.editDataset(dataset) From b2c77db84cb9e69aa2f49ac58b2754905cdbd18a Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 14 Jul 2025 12:27:01 +0000 Subject: [PATCH 44/50] lint lint --- .../modal/modal-header.component.html | 12 ++-- .../data-mappings/data-mapping.component.html | 10 +-- .../step4/results.component.html | 17 ++--- .../data-upload-modal.component.html | 30 ++++---- .../data-upload-modal.component.ts | 2 +- .../meter-upload-modal.component.html | 68 +++++++++++-------- .../meter-upload-modal.component.ts | 2 +- .../datasets/modal/form-modal.component.html | 5 +- .../green-button-upload-modal.component.ts | 2 +- .../modal/sensors-upload.component.html | 6 +- 10 files changed, 81 insertions(+), 73 deletions(-) diff --git a/src/@seed/components/modal/modal-header.component.html b/src/@seed/components/modal/modal-header.component.html index bcb9f1a7..20b90964 100644 --- a/src/@seed/components/modal/modal-header.component.html +++ b/src/@seed/components/modal/modal-header.component.html @@ -1,7 +1,8 @@ -
-
+
+
+ class="flex h-10 w-10 flex-0 items-center justify-center rounded-full bg-primary-100 text-primary-600 sm:mr-4 dark:bg-primary-600 dark:text-primary-50" + >
@@ -13,10 +14,11 @@ @if (close) {
+ (click)="close()" + >
}
- \ No newline at end of file + diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 913dba89..498106d9 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -65,11 +65,11 @@ diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index d2283567..79b394b4 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -7,16 +7,17 @@ - @if(showMeterButton) { - + (click)="importMeters()" + mat-raised-button + color="primary" + > + Import meters + } -
diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.html b/src/app/modules/datasets/data-upload/data-upload-modal.component.html index 6fd55479..4bf74444 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ @for (cycle of cycles; track $index) { - {{ cycle.name }} + {{ cycle.name }} } @@ -24,8 +24,13 @@ - +
@@ -35,8 +40,7 @@ .csv, .xls, .xslx
-
Note: only the first sheet of multi-sheet Excel files will be imported. -
+
Note: only the first sheet of multi-sheet Excel files will be imported.
- @@ -59,18 +62,17 @@
@if (uploading) { - + } @else { - -
+ +
} @if (inProgress) { - + } @@ -84,4 +86,4 @@
-
\ No newline at end of file + diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts index b7b7d963..8e8c5e98 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -1,7 +1,7 @@ import type { StepperSelectionEvent } from '@angular/cdk/stepper' import { CommonModule } from '@angular/common' import type { HttpErrorResponse } from '@angular/common/http' -import type { AfterViewInit, ElementRef, OnDestroy} from '@angular/core' +import type { AfterViewInit, ElementRef, OnDestroy } from '@angular/core' import { Component, inject, ViewChild } from '@angular/core' import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html index 0b73690e..1a8309ba 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -1,4 +1,4 @@ - + @@ -7,16 +7,17 @@
Accepted File Types: .csv, .xls, .xlsx
- - - @@ -25,30 +26,31 @@
-
Note: only the first sheet of multi-sheet Excel files will be imported.
- +
Note: only the first sheet of multi-sheet Excel files will be imported.
- + @if (uploading) { - + } @else {
-
+
} -
+
{{ readingGridTitle }}
- - + +
-
+
Parsed Energy Types and Units
- +
-
+
{{ readingGridTitle }}
- - + +
- +
- \ No newline at end of file + diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index e950db01..5da7d58f 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -6,7 +6,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatProgressBarModule } from '@angular/material/progress-bar' -import type { MatStepper } from '@angular/material/stepper'; +import type { MatStepper } from '@angular/material/stepper' import { MatStepperModule } from '@angular/material/stepper' import { AgGridAngular } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' diff --git a/src/app/modules/datasets/modal/form-modal.component.html b/src/app/modules/datasets/modal/form-modal.component.html index b9fd7764..29ad8b5e 100644 --- a/src/app/modules/datasets/modal/form-modal.component.html +++ b/src/app/modules/datasets/modal/form-modal.component.html @@ -1,7 +1,4 @@ - + diff --git a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts index 75e73088..0f70b744 100644 --- a/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts +++ b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.ts @@ -19,7 +19,7 @@ import { MeterService } from '@seed/api/meters' import type { ProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' import { ConfigService } from '@seed/services' -import type { ProgressBarObj, MeterImport, ValidatedTypeUnit } from '@seed/services/uploader' +import type { MeterImport, ProgressBarObj, ValidatedTypeUnit } from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' diff --git a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html index 17158699..35b38be5 100644 --- a/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html +++ b/src/app/modules/inventory-detail/sensors/data-loggers/modal/sensors-upload.component.html @@ -87,11 +87,7 @@ @if (proposedImports.length) { - + }
From b71adb96ee54705ed7d09190ef51c2d9ea711d88 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 16 Jul 2025 20:42:33 +0000 Subject: [PATCH 45/50] wording, success notifications, remove mapping pagination --- cspell.json | 1 + .../data-mappings/data-mapping.component.html | 22 +++-- .../data-mappings/data-mapping.component.ts | 11 +++ .../data-mappings/help.component.html | 96 ++++++++++++++----- .../datasets/data-mappings/help.component.ts | 5 +- .../step1/map-data.component.html | 4 +- .../data-mappings/step1/map-data.component.ts | 9 ++ .../step4/match-merge.component.ts | 7 +- .../step4/results.component.html | 3 +- .../data-upload-modal.component.html | 21 ++-- .../data-upload-modal.component.ts | 8 +- .../meter-upload-modal.component.ts | 1 + src/styles/styles.scss | 4 + 13 files changed, 139 insertions(+), 53 deletions(-) diff --git a/cspell.json b/cspell.json index ab684b69..1de76a65 100644 --- a/cspell.json +++ b/cspell.json @@ -20,6 +20,7 @@ ], "useGitignore": true, "words": [ + "BEDES", "CEJST", "eeej", "EPSG", diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.html b/src/app/modules/datasets/data-mappings/data-mapping.component.html index 498106d9..f5ac8942 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.html +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -19,15 +19,24 @@ - + - - - check + + check + + @if (completed[index + 1]) { + check + } @else { + {{ index + 1 }} + } @@ -50,7 +59,7 @@ - + - + diff --git a/src/app/modules/datasets/data-mappings/data-mapping.component.ts b/src/app/modules/datasets/data-mappings/data-mapping.component.ts index 594357b2..2dfb8d6c 100644 --- a/src/app/modules/datasets/data-mappings/data-mapping.component.ts +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -88,7 +88,9 @@ export class DataMappingComponent implements OnDestroy, OnInit { inventoryType: InventoryType = 'properties' mappingResultsResponse: MappingResultsResponse mappingSuggestions: MappingSuggestionsResponse + matchingPropertyColumnDisplayNames = '' matchingPropertyColumns: string[] = [] + matchingTaxLotColumnDisplayNames = '' matchingTaxLotColumns: string[] = [] org: Organization orgId: number @@ -168,6 +170,12 @@ export class DataMappingComponent implements OnDestroy, OnInit { this.matchingTaxLotColumns = matchingTaxLotColumns as string[] this.propertyColumns = propertyColumns this.taxlotColumns = taxlotColumns + + const propertyMap = new Map(propertyColumns.filter((c) => c.table_name === 'PropertyState').map((c) => [c.column_name, c.display_name])) + const taxlotMap = new Map(taxlotColumns.filter((c) => c.table_name === 'TaxLotState').map((c) => [c.column_name, c.display_name])) + + this.matchingPropertyColumnDisplayNames = this.matchingPropertyColumns.map((name) => propertyMap.get(name) || name).join(', ') + this.matchingTaxLotColumnDisplayNames = this.matchingTaxLotColumns.map((name) => taxlotMap.get(name) || name).join(', ') }), ) } @@ -240,6 +248,9 @@ export class DataMappingComponent implements OnDestroy, OnInit { startMatchMerge() { this.nextStep(3) this.matchMergeComponent.startMatchMerge() + } + + onMatchComplete() { this.completed[4] = true } diff --git a/src/app/modules/datasets/data-mappings/help.component.html b/src/app/modules/datasets/data-mappings/help.component.html index dc2d5ae9..0c0c04c2 100644 --- a/src/app/modules/datasets/data-mappings/help.component.html +++ b/src/app/modules/datasets/data-mappings/help.component.html @@ -1,37 +1,81 @@ -
-
MAPPING YOUR DATA TO SEED
+@if (!completed[1]) { + -
- It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is - based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name - from the original datafile. -
+
+
MAPPING YOUR DATA TO SEED
-
- In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data - is matched and merged, as well as how it is displayed in the Inventory view. -
+
+ It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is + based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field + name from the original datafile. +
-
- Column Mapping Profiles can be used to help you easily and consistently map your data. Note that file header columns defined in the - profile must match exactly (spaces, lowercase, uppercase, etc.) in order for the corresponding SEED column information to be used. -
+
+ In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the + data is matched and merged, as well as how it is displayed in the Inventory view. +
+ +
+ Column Mapping Profiles can be used to help you easily and consistently map your data. Note that file header columns defined in the + profile must match exactly (spaces, lowercase, uppercase, etc.) in order for the corresponding SEED column information to be used. +
-
Field names for matching Properties: Custom ID 1, PM Property ID
+
+ Field names for matching Properties: + + {{ matchingPropertyColumnDisplayNames }} + +
-
Field names for matching Tax Lots: Custom ID 1, Jurisdiction Tax Lot ID
+
+ Field names for matching Tax Lots: + + {{ matchingTaxLotColumnDisplayNames }} + +
-
- If there are fields in the datafile mapped to these names, the program will attempt to match on the corresponding values in existing - records. All of these fields must have the same values between records for the records to match. +
+ If there are fields in the datafile mapped to these names, the program will attempt to match on the corresponding values in existing + records. All of these fields must have the same values between records for the records to match. +
+ +
+ Matches within the same cycle will be merged together, while matches in different cycles will be associated for cross-cycle analysis. +
+ +
+ Map Your Data The program will show a grid with the new field names as the column headings and your + data in the rows. In that view, you can still come back to the initial mapping screen and change the field mapping. +
+} @else if (!completed[3]) { + +
+
REVIEW YOUR DATA MAPPINGS
-
- Matches within the same cycle will be merged together, while matches in different cycles will be associated for cross-cycle analysis. +
+ Map Your Data + You will see a view with the mapped fields and your data in a grid, so that you can verify the mapping is what you want. +
+
+ Back to Mapping If you need to change the mapping, click the button and edit the field mapping. +
+
+ Save Mappings Make sure that the mappings are correct in this step. Once you have clicked on the + button, you will not be able to change the field mappings. +
+} @else if (false) { + +
+
MAPPING YOUR DATA TO SEED
+ +
+ The Building Energy Data Exchange Specification (BEDES, pronounced "beads" or /bi:ds/) is designed to support analysis of the measured + energy performance of commercial, multifamily, and residential buildings, by providing a common data format, definitions, and an + exchange protocol for building characteristics, efficiency measures, and energy use. +
-
- When you click the Map Your Data button, the program will show a grid with the new field names as the column headings and your data in - the rows. In that view, you can still come back to the initial mapping screen and change the field mapping. + Read more
-
+} diff --git a/src/app/modules/datasets/data-mappings/help.component.ts b/src/app/modules/datasets/data-mappings/help.component.ts index a6c633e7..42ee6fe5 100644 --- a/src/app/modules/datasets/data-mappings/help.component.ts +++ b/src/app/modules/datasets/data-mappings/help.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core' +import { Component, Input } from '@angular/core' @Component({ selector: 'seed-data-mapping-help', @@ -6,4 +6,7 @@ import { Component } from '@angular/core' imports: [], }) export class HelpComponent { + @Input() completed: Record + @Input() matchingPropertyColumnDisplayNames: string + @Input() matchingTaxLotColumnDisplayNames: string } 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 51d6bd11..a6d3dba5 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 @@ -76,9 +76,7 @@ [columnDefs]="columnDefs" [gridOptions]="gridOptions" [theme]="gridTheme$ | async" - [pagination]="true" - [paginationPageSize]="20" - [domLayout]="'autoHeight'" + [style.height.px]="gridHeight" (gridReady)="onGridReady($event)" > diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index 5398b9fc..d1643c2d 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -79,6 +79,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { errorMessages: string[] = [] fileId = this._router.snapshot.params.id as number gridApi: GridApi + gridHeight = 0 gridOptions = gridOptions gridTheme$ = this._configService.gridTheme$ mappedData: { mappings: DataMappingRow[] } = { mappings: [] } @@ -150,12 +151,20 @@ export class MapDataComponent implements OnChanges, OnDestroy { for (const row of this.rowData) { row.omit = false } + this.getGridHeight() } onGridReady(agGrid: GridReadyEvent) { this.gridApi = agGrid.api } + getGridHeight() { + const vh = window.innerHeight - 285 // header + footer + if (!this.rowData?.length) return + + this.gridHeight = Math.min(this.rowData.length * 42 + 50, vh) + } + setAllInventoryType(value: InventoryDisplayType) { this.defaultInventoryType = value this.defaultInventoryTypeChange.emit(value) diff --git a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts index e69f414e..6f7c3fdc 100644 --- a/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common' import type { OnDestroy } from '@angular/core' -import { Component, inject, Input } from '@angular/core' +import { Component, EventEmitter, inject, Input, Output } from '@angular/core' import { MatButtonModule } from '@angular/material/button' import { RouterModule } from '@angular/router' import { of, Subject, switchMap, takeUntil } from 'rxjs' @@ -9,6 +9,7 @@ import type { ProgressResponse, SubProgressResponse } from '@seed/api/progress' import { ProgressBarComponent } from '@seed/components' import type { CheckProgressLoopParams } from '@seed/services/uploader' import { UploaderService } from '@seed/services/uploader' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType } from 'app/modules/inventory' import { ResultsComponent } from './results.component' @@ -29,9 +30,11 @@ export class MatchMergeComponent implements OnDestroy { @Input() importFileId: number @Input() inventoryType: InventoryType @Input() orgId: number + @Output() matchMergeComplete = new EventEmitter() private _mappingService = inject(MappingService) private _uploaderService = inject(UploaderService) + private _snackBar = inject(SnackBarService) private readonly _unsubscribeAll$ = new Subject() inProgress = true @@ -60,7 +63,9 @@ export class MatchMergeComponent implements OnDestroy { checkProgress(data: SubProgressResponse) { const successFn = () => { + this.matchMergeComplete.emit() this.inProgress = false + this._snackBar.success('Data Upload Complete') } const { progress_data, sub_progress_data } = data diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.html b/src/app/modules/datasets/data-mappings/step4/results.component.html index 79b394b4..29f412e4 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.html +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -1,4 +1,5 @@ -
+
+ Upload Complete @if (matchingResults) {
diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.html b/src/app/modules/datasets/data-upload/data-upload-modal.component.html index 4bf74444..56d6c5a4 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -1,4 +1,4 @@ - + @@ -73,17 +73,14 @@ @if (inProgress) { + } @else { +
+ + +
}
- - - -
- - -
-
diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts index 8e8c5e98..62e525a2 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -79,6 +79,7 @@ export class DataUploadModalComponent implements AfterViewInit, OnDestroy { progressLastChecked: null, } sourceType: 'Assessed Raw' | 'GeoJSON' | 'BuildingSync Raw' + title = 'Upload Property / Tax Lot Data' uploading = false userSettings: OrganizationUserSettings = {} @@ -151,17 +152,18 @@ export class DataUploadModalComponent implements AfterViewInit, OnDestroy { } step2(progressKey: string) { + this.title = 'Uploading...' this.inProgress = true const failureFn = () => { this._snackBar.alert('File Upload Failed') + this.inProgress = false } const successFn = () => { this.completed[2] = true - setTimeout(() => { - this.stepper.next() - }) + this.inProgress = false + this.title = 'Upload Complete' } this._uploaderService diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index 5da7d58f..6f68fb81 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -137,6 +137,7 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { this.importedMeters = this.progressBarObj.message as MeterImport[] this.setReadingTitle(this.importedMeters) setTimeout(() => { + this._snackBar.success('Meter Upload Complete') this.stepper.next() }) } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index eabaf340..5cf5ee5b 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -217,3 +217,7 @@ .vertical-divider { @apply border } + +.mock-button { + @apply border border-primary bg-primary py-1 px-2 rounded-full whitespace-nowrap text-white +} From 3259c9e38b53e2dc53477cef462a6293da1e052f Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 16 Jul 2025 21:02:29 +0000 Subject: [PATCH 46/50] prevent overlapping uploads --- .../data-mappings/step1/map-data.component.ts | 2 +- .../data-upload/data-upload-modal.component.html | 7 +++---- .../data-upload/data-upload-modal.component.ts | 15 +++------------ 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index d1643c2d..4a4cf6ec 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -162,7 +162,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { const vh = window.innerHeight - 285 // header + footer if (!this.rowData?.length) return - this.gridHeight = Math.min(this.rowData.length * 42 + 50, vh) + this.gridHeight = Math.min(this.rowData.length * 42 + 100, vh) } setAllInventoryType(value: InventoryDisplayType) { diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.html b/src/app/modules/datasets/data-upload/data-upload-modal.component.html index 56d6c5a4..964f97c0 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -1,8 +1,7 @@ - - + - +
@@ -75,7 +74,7 @@ } @else {
- +
-} @else if (false) { - -
} From 5e337d6522ccd1b51d682eb53785e58197c08294 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 17 Jul 2025 17:34:44 +0000 Subject: [PATCH 49/50] default to fileHeader --- .../modules/datasets/data-mappings/step1/map-data.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index 4a4cf6ec..da0316e6 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -231,7 +231,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { const fileHeader = node.data.from_field const suggestedColumnName = suggested_column_mappings[fileHeader][1] - const displayName = columnMap[suggestedColumnName] + const displayName = columnMap[suggestedColumnName] ?? fileHeader node.setDataValue('to_field_display_name', displayName) }) } From 1cdaa58c82e16bde66d65d03bd9bafd13f6bffd5 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 17 Jul 2025 18:49:57 +0000 Subject: [PATCH 50/50] reuse inv file for meters --- src/@seed/api/dataset/dataset.service.ts | 11 ++++ .../services/uploader/uploader.service.ts | 8 +-- .../data-mappings/step4/results.component.ts | 15 ++--- .../data-upload-modal.component.ts | 1 - .../meter-upload-modal.component.html | 2 +- .../meter-upload-modal.component.ts | 58 ++++++++++++++----- 6 files changed, 60 insertions(+), 35 deletions(-) diff --git a/src/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index 81fb5119..61f7aca4 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -132,4 +132,15 @@ export class DatasetService { }), ) } + + reuseInventoryFileForMeters(orgId: number, fileId: number): Observable { + const url = '/api/v3/import_files/reuse_inventory_file_for_meters/' + const data = { organization_id: orgId, import_file_id: fileId } + return this._httpClient.post<{ import_file_id: number }>(url, data).pipe( + map(({ import_file_id }) => import_file_id), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error reusing file for meter data') + }), + ) + } } diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index b98d058b..d82e091f 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.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 { catchError, combineLatest, finalize, interval, of, ReplaySubject, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' +import { catchError, combineLatest, finalize, interval, of, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' import type { ProgressResponse } from '@seed/api/progress' import { ErrorService } from '../error' import type { @@ -20,8 +20,6 @@ import type { export class UploaderService { private _httpClient = inject(HttpClient) private _errorService = inject(ErrorService) - private _file = new ReplaySubject(1) - file$ = this._file.asObservable() get defaultProgressBarObj(): ProgressBarObj { return { @@ -35,10 +33,6 @@ export class UploaderService { } } - setFile(file: File) { - this._file.next(file) - } - /* * Checks a progress key for updates until it completes */ diff --git a/src/app/modules/datasets/data-mappings/step4/results.component.ts b/src/app/modules/datasets/data-mappings/step4/results.component.ts index 7666b901..2b723f88 100644 --- a/src/app/modules/datasets/data-mappings/step4/results.component.ts +++ b/src/app/modules/datasets/data-mappings/step4/results.component.ts @@ -123,17 +123,10 @@ export class ResultsComponent implements OnChanges, OnDestroy { } importMeters() { - this.showMeterButton = false - this._uploaderService.file$ - .pipe( - take(1), - tap((file) => { - this._dialog.open(MeterDataUploadModalComponent, { - width: '60rem', - data: { orgId: this.orgId, datasetId: this.datasetId, cycleId: this.cycleId, file }, - }) - }), - ).subscribe() + this._dialog.open(MeterDataUploadModalComponent, { + width: '60rem', + data: { orgId: this.orgId, datasetId: this.datasetId, cycleId: this.cycleId, reusedImportFileId: this.importFileId }, + }) } ngOnDestroy(): void { diff --git a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts index 511fc6b2..f174a23e 100644 --- a/src/app/modules/datasets/data-upload/data-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -108,7 +108,6 @@ export class DataUploadModalComponent implements AfterViewInit, OnDestroy { step1(fileList: FileList) { this.file = fileList?.[0] - this._uploaderService.setFile(this.file) const cycleId = this.form.get('cycleId')?.value const multiCycle = this.form.get('multiCycle')?.value this.uploading = true diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html index 1a8309ba..42b7a78e 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.html +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.html @@ -22,7 +22,7 @@ Choose file
- {{ file?.name ?? 'No file selected' }} + {{ file?.name ?? defaultFileName }}
diff --git a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts index 6f68fb81..6788c46d 100644 --- a/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts +++ b/src/app/modules/datasets/data-upload/meter-upload-modal.component.ts @@ -10,7 +10,8 @@ import type { MatStepper } from '@angular/material/stepper' import { MatStepperModule } from '@angular/material/stepper' import { AgGridAngular } from 'ag-grid-angular' import type { ColDef } from 'ag-grid-community' -import { catchError, EMPTY, Subject, switchMap, takeUntil, tap } from 'rxjs' +import { catchError, EMPTY, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import { DatasetService } from '@seed/api/dataset' import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { ConfigService } from '@seed/services' import { UploaderService } from '@seed/services/uploader/uploader.service' @@ -37,6 +38,7 @@ import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { @ViewChild('stepper') stepper!: MatStepper + private _datasetService = inject(DatasetService) private _dialogRef = inject(MatDialogRef) private _configService = inject(ConfigService) private _uploaderService = inject(UploaderService) @@ -49,9 +51,10 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { 'text/plain', ] file?: File - fileId: number + importFileId: number cycleId: number completed = { 1: false, 2: false, 3: false } + defaultFileName = 'No file selected' inProgress = false readingGridTitle: string uploading = false @@ -81,15 +84,30 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { { field: 'parsed_unit', headerName: 'Unit', flex: 1 }, ] - data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number; cycleId: number; file: File } + data = inject(MAT_DIALOG_DATA) as { datasetId: string; orgId: number; cycleId: number; reusedImportFileId: number } ngAfterViewInit(): void { - // if a file is passed, start upload immediately (from the property upload stepper) - if (this.data.file) { - this.skipToStep1() + // if a file is passed from the inventory upload stepper, start upload immediately + if (this.data.reusedImportFileId) { + this.defaultFileName = 'Reusing inventory file' + this.reuseInventoryFileForMeters() } } + reuseInventoryFileForMeters() { + this.uploading = true + this.completed[1] = true + this.step1ProgressTitle = 'Analyzing file...' + + this._datasetService.reuseInventoryFileForMeters(this.data.orgId, this.data.reusedImportFileId) + .pipe( + take(1), + tap((importFileId) => this.importFileId = importFileId), + switchMap(() => this.getMetersPreview()), + ) + .subscribe() + } + step1(fileList: FileList) { if (fileList.length !== 1) return const { orgId, datasetId } = this.data @@ -103,11 +121,19 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { .pipe( takeUntil(this._unsubscribeAll$), tap(({ import_file_id }) => { - this.fileId = import_file_id + this.importFileId = import_file_id this.completed[1] = true this.step1ProgressTitle = 'Analyzing file...' }), - switchMap(() => this._uploaderService.metersPreview(orgId, this.fileId)), + switchMap(() => this.getMetersPreview()), + ) + .subscribe() + } + + getMetersPreview() { + const { orgId } = this.data + return this._uploaderService.metersPreview(orgId, this.importFileId) + .pipe( tap((response) => { const { proposed_imports, validated_type_units } = response this.setReadingTitle(proposed_imports) @@ -124,7 +150,6 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { return EMPTY }), ) - .subscribe() } step3() { @@ -144,7 +169,7 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { const failureFn = () => this.inProgress = false - this._uploaderService.saveRawData(orgId, cycleId, this.fileId) + this._uploaderService.saveRawData(orgId, cycleId, this.importFileId) .pipe( switchMap(({ progress_key }) => this._uploaderService.checkProgressLoop({ progressKey: progress_key, @@ -152,6 +177,14 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { failureFn, progressBarObj: this.progressBarObj, })), + catchError(() => { + this.completed[3] = true + setTimeout(() => { + this._snackBar.alert('Error Uploading Meters') + this.stepper.next() + }) + return EMPTY + }), ) .subscribe() } @@ -171,11 +204,6 @@ export class MeterDataUploadModalComponent implements AfterViewInit, OnDestroy { csvDownload(title, data[title]) } - skipToStep1() { - const fileList = this.createFileList(this.data.file) - this.step1(fileList) - } - createFileList(file: File) { const dt = new DataTransfer() dt.items.add(file)