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..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 @@ -2,14 +2,16 @@ 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, ColumnMappingProfile, ColumnMappingProfileDeleteResponse, ColumnMappingProfilesRequest, + ColumnMappingProfileType, ColumnMappingProfileUpdateResponse, ColumnMappingSuggestionResponse, } from './column_mapping_profile.types' @@ -20,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() @@ -30,9 +33,13 @@ export class ColumnMappingProfileService { }) } - getProfiles(org_id: number): Observable { - const url = `/api/v3/column_mapping_profiles/filter/?organization_id=${org_id}` - return this._httpClient.post(url, {}).pipe( + 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 + } + return this._httpClient.post(url, data).pipe( map((response) => { this._profiles.next(response.data) return response.data @@ -44,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 @@ -56,20 +63,21 @@ 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( + 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 }), + tap(() => { this._snackBar.success('Profile updated successfully') }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating profile') }), ) } - 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 @@ -80,17 +88,18 @@ 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) => { return this._errorService.handleError(error, 'Error creating profile') }), ) } - 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' }) @@ -101,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/@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/cycle/cycle.service.ts b/src/@seed/api/cycle/cycle.service.ts index 6c6b02e1..620f3bf0 100644 --- a/src/@seed/api/cycle/cycle.service.ts +++ b/src/@seed/api/cycle/cycle.service.ts @@ -3,15 +3,15 @@ import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' import { BehaviorSubject, catchError, map, take, tap } from 'rxjs' -import { OrganizationService } from '@seed/api/organization' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { UserService } from '../user' import type { Cycle, CycleResponse, CyclesResponse } from './cycle.types' @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 +20,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 +45,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 +76,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 +88,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/data-quality/data-quality.service.ts b/src/@seed/api/data-quality/data-quality.service.ts index 7fa0281d..f26f5ac4 100644 --- a/src/@seed/api/data-quality/data-quality.service.ts +++ b/src/@seed/api/data-quality/data-quality.service.ts @@ -1,10 +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 type { DQCProgressResponse } from '../progress' +import type { DataQualityResults, DataQualityResultsResponse, Rule } from './data-quality.types' @Injectable({ providedIn: 'root' }) export class DataQualityService { @@ -79,4 +80,38 @@ 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') + }), + ) + } + + 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( + 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/@seed/api/dataset/dataset.service.ts b/src/@seed/api/dataset/dataset.service.ts index 9e82cfee..68b8538f 100644 --- a/src/@seed/api/dataset/dataset.service.ts +++ b/src/@seed/api/dataset/dataset.service.ts @@ -2,42 +2,123 @@ 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, 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 { CountDatasetsResponse, Dataset, ListDatasetsResponse } from './dataset.types' +import type { CountDatasetsResponse, Dataset, DatasetResponse, ImportFile, ImportFileResponse, ListDatasetsResponse } from './dataset.types' @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() } - listDatasets(organizationId: number): Observable { - return this._httpClient - .get(`/api/v3/datasets/?organization_id=${organizationId}`) - .pipe(map(({ datasets }) => datasets)) + 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() } - 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 + 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( + 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') }), + ) + } + + 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') + }), + ) + } + + 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) => { + 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() + } + + 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') + }), + ) + } + + getImportFile(orgId: number, fieldId: number): Observable { + const url = `/api/v3/import_files/${fieldId}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ import_file }) => import_file), 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 fetching import file') }), ) } diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 83c17f0e..1134c480 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,35 @@ export type CountDatasetsResponse = { status: 'success'; datasets_count: number; } + +export type DatasetResponse = { + status: 'success'; + dataset: Dataset; +} + +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 +} + +export type MappedData = { + mappings: DataMappingRow[]; +} + +export type MappingResultsResponse = { + status: string; + properties: Record[]; + tax_lots: Record[]; +} 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/@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..60cc1040 --- /dev/null +++ b/src/@seed/api/mapping/mapping.service.ts @@ -0,0 +1,109 @@ +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 type { FirstFiveRowsResponse, MappingSuggestionsResponse, MatchingResultsResponse, 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( + map(({ raw_columns }) => raw_columns), + 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( + map(({ first_five_rows }) => first_five_rows), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching first five rows') + }), + ) + } + + 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( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching mapping results') + }), + ) + } + + 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}` + // returns ProgressResponse if already matched + return this._httpClient.post(url, {}) + .pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error starting match merge') + }), + ) + } + + 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 new file mode 100644 index 00000000..c5db1412 --- /dev/null +++ b/src/@seed/api/mapping/mapping.types.ts @@ -0,0 +1,46 @@ +import type { Column } from '../column' + +export type MappingSuggestionsResponse = { + status: string; + property_columns: Column[]; + suggested_column_mappings: SuggestedColumnMapping; + taxlot_columns: Column[]; +} + +export type SuggestedColumnMapping = Record + +export type RawColumnNamesResponse = { + status: string; + raw_columns: string[]; +} + +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/@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/components/ag-grid/autocomplete.component.html b/src/@seed/components/ag-grid/autocomplete.component.html new file mode 100644 index 00000000..f24ce47b --- /dev/null +++ b/src/@seed/components/ag-grid/autocomplete.component.html @@ -0,0 +1,10 @@ + + + + @for (option of filteredOptions; track $index) { + + {{ option }} + + } + + 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..1c31c081 --- /dev/null +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -0,0 +1,53 @@ +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' + +@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!: ICellEditorParams & { values?: string[] } + options: string[] = [] + + agInit(params: ICellEditorParams & { values?: string[] }): void { + this.params = params + this.options = params.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 option.toLowerCase().startsWith(value.toLowerCase()) + }) + // update after each keystroke + this.params.node.setDataValue(this.params.column.getId(), value) + }) + } + + getValue() { + return this.inputCtrl.value + } + + ngAfterViewInit(): void { + setTimeout(() => { + this.input.nativeElement.focus() + }) + } +} 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..84938b4e --- /dev/null +++ b/src/@seed/components/ag-grid/index.ts @@ -0,0 +1 @@ +export * from './editHeader.component' diff --git a/src/@seed/components/column-profiles/column-profiles.component.ts b/src/@seed/components/column-profiles/column-profiles.component.ts index f9603121..51407c40 100644 --- a/src/@seed/components/column-profiles/column-profiles.component.ts +++ b/src/@seed/components/column-profiles/column-profiles.component.ts @@ -188,7 +188,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { // return ` // // push_pin // 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/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/@seed/components/progress/progress-bar.component.html b/src/@seed/components/progress/progress-bar.component.html index 1a4c5ed5..1e31e892 100644 --- a/src/@seed/components/progress/progress-bar.component.html +++ b/src/@seed/components/progress/progress-bar.component.html @@ -1,13 +1,38 @@
-
-
- -
{{ 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/error/error.service.ts b/src/@seed/services/error/error.service.ts index d61298d1..25fe0e73 100644 --- a/src/@seed/services/error/error.service.ts +++ b/src/@seed/services/error/error.service.ts @@ -29,7 +29,7 @@ export class ErrorService { const isStr = typeof err === 'string' // If the string is too long (likely html '...'), return the default message - if (isStr && err.length > 1000) return defaultMessage + if (isStr && (err.length > 1000 || err.startsWith(''))) return defaultMessage if (isStr) return err const isObj = typeof err === 'object' && err !== null diff --git a/src/@seed/services/uploader/uploader.service.ts b/src/@seed/services/uploader/uploader.service.ts index 34b807ee..2c106d94 100644 --- a/src/@seed/services/uploader/uploader.service.ts +++ b/src/@seed/services/uploader/uploader.service.ts @@ -2,12 +2,13 @@ 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, finalize, interval, of, Subject, switchMap, takeUntil, takeWhile, tap, throwError } from 'rxjs' import type { ProgressResponse } from '@seed/api/progress' 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 */ @@ -29,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(() => { @@ -47,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$]) } /* 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/@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/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-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..777c8ac1 --- /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
+ } +
+ +
+ +
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..d2387903 --- /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 { 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', + 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/data/data.component.html b/src/app/modules/data/data.component.html deleted file mode 100644 index b8dad0b0..00000000 --- a/src/app/modules/data/data.component.html +++ /dev/null @@ -1,69 +0,0 @@ - - -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Dataset - - {{ dataset.name }} - - Files - - {{ dataset.importfiles.length }} - - Last Changed - - {{ dataset.updated_at | date: 'MMM dd, y' }} - - Changed By - - {{ dataset.last_modified_by }} - - Actions - Actions here -
-
-
-
-
-
- -
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..4f57ef1a --- /dev/null +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.html @@ -0,0 +1,70 @@ + +
+ + + + + + +
+
+ + + + + + + + + + + + check + + + + + + + + + + + + + + + + + + + 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..e9f62ae4 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/data-mapping.component.ts @@ -0,0 +1,270 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } 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' +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 { catchError, filter, forkJoin, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import type { ColumnMappingProfile, ColumnMappingProfileType } from '@seed/api/column_mapping_profile' +import { ColumnMappingProfileService } from '@seed/api/column_mapping_profile' +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 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 { UploaderService } from '@seed/services/uploader' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +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' +import { MatchMergeComponent } from './step4/match-merge.component' + +@Component({ + selector: 'seed-data-mapping-stepper', + templateUrl: './data-mapping.component.html', + imports: [ + AgGridAngular, + CommonModule, + FormsModule, + HelpComponent, + MapDataComponent, + MatchMergeComponent, + MatButtonModule, + MatButtonToggleModule, + MatDividerModule, + MatIconModule, + MatSidenavModule, + MatSelectModule, + MatStepperModule, + PageComponent, + ProgressBarComponent, + ReactiveFormsModule, + SaveMappingsComponent, + ], +}) +export class DataMappingComponent implements OnDestroy, OnInit { + @ViewChild('stepper') stepper!: MatStepper + @ViewChild(MapDataComponent) mapDataComponent!: MapDataComponent + @ViewChild(MatchMergeComponent) matchMergeComponent!: MatchMergeComponent + private readonly _unsubscribeAll$ = new Subject() + private _columnMappingProfileService = inject(ColumnMappingProfileService) + private _columnService = inject(ColumnService) + private _cycleService = inject(CycleService) + private _datasetService = inject(DatasetService) + 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[] + columnMappingProfiles: ColumnMappingProfile[] = [] + columnMappingProfileTypes: ColumnMappingProfileType[] + columnNames: string[] + completed = { 1: false, 2: false, 3: false, 4: false } + currentProfile: Profile + cycle: Cycle + fileId = this._router.snapshot.params.id as number + firstFiveRows: Record[] + helpOpened = false + importFile: ImportFile + inventoryType: InventoryType = 'properties' + mappingResultsResponse: MappingResultsResponse + mappingSuggestions: MappingSuggestionsResponse + matchingPropertyColumns: string[] = [] + matchingTaxLotColumns: string[] = [] + org: Organization + orgId: number + propertyColumns: Column[] + rawColumnNames: string[] = [] + taxlotColumns: Column[] + + progressBarObj = this._uploaderService.defaultProgressBarObj + + ngOnInit(): void { + // this._userService.currentOrganizationId$ + this._organizationService.currentOrganization$ + .pipe( + take(1), + tap((org) => { + this.orgId = org.id + this.org = org + }), + switchMap(() => this.getImportFile()), + filter(Boolean), + tap(() => { this.getProfiles() }), + switchMap(() => this.getMappingData()), + switchMap(() => this.getColumns()), + ) + .subscribe() + } + + getImportFile() { + return this._datasetService.getImportFile(this.orgId, this.fileId) + .pipe( + take(1), + tap((importFile) => { + this.importFile = importFile + this.columnMappingProfileTypes = importFile.source_type === 'BuildingSync Raw' ? ['BuildingSync Default', 'BuildingSync Custom'] : ['Normal'] + }), + catchError(() => { + 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(([cycle, firstFiveRows, mappingSuggestions, rawColumnNames]) => { + this.cycle = cycle + this.firstFiveRows = firstFiveRows + this.mappingSuggestions = mappingSuggestions + this.rawColumnNames = rawColumnNames + }), + ) + } + + 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, + propertyColumns, + taxlotColumns, + ]) => { + this.matchingPropertyColumns = matchingPropertyColumns as string[] + this.matchingTaxLotColumns = matchingTaxLotColumns as string[] + this.propertyColumns = propertyColumns + this.taxlotColumns = taxlotColumns + }), + ) + } + + 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() + } + + startMapping() { + const mappedData = this.mapDataComponent.mappedData + this.columns = this.mapDataComponent.defaultInventoryType === 'Tax Lot' ? this.taxlotColumns : this.propertyColumns + this.nextStep(1) + + const failureFn = () => { + this._snackBar.alert('Error starting mapping') + } + const successFn = () => { + this.nextStep(2) + this.getMappingResults() + } + + this._mappingService.startMapping(this.orgId, this.fileId, mappedData) + .pipe( + 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, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + }), + takeUntil(this._unsubscribeAll$), + catchError((error) => { + console.log('Error starting mapping:', error) + return of(null) + }), + ) + .subscribe() + } + + getMappingResults(): void { + this.nextStep(2) + this._mappingService.mappingResults(this.orgId, this.fileId) + .pipe( + tap((mappingResultsResponse) => { this.mappingResultsResponse = mappingResultsResponse }), + ) + .subscribe() + } + + startMatchMerge() { + this.nextStep(3) + this.matchMergeComponent.startMatchMerge() + this.completed[4] = true + } + + nextStep(currentStep: number) { + this.completed[currentStep] = true + setTimeout(() => { + this.stepper.next() + }) + } + + 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/help.component.html b/src/app/modules/datasets/data-mappings/help.component.html new file mode 100644 index 00000000..dc2d5ae9 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/help.component.html @@ -0,0 +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. +
+ +
+ 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..a6c633e7 --- /dev/null +++ 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 { +} 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-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts new file mode 100644 index 00000000..d474d645 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -0,0 +1,169 @@ +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 { 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 = (to_data_type: string, field: string, isNewColumn: boolean): boolean => { + const editMap: Record = { + 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] +} + +const dropdownRenderer = (params: ICellRendererParams) => { + const value = params.value as string + const data = params.data as { to_data_type: string; isNewColumn: boolean } + const field = params.colDef.field + + if (!canEdit(data.to_data_type, field, data.isNewColumn)) { + return value + } + + return ` +
+ ${value ?? ''} + arrow_drop_down +
+ ` +} + +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 = ( + propertyColumnNames: string[], + taxlotColumnNames: string[], + uploadedFilename: string, + seedHeaderChange: (event: CellValueChangedEvent) => void, + dataTypeChange: (event: CellValueChangedEvent) => void, + validateData: (event?: CellValueChangedEvent) => void, +): (ColDef | ColGroupDef)[] => { + const seedCols: ColDef[] = [ + { field: 'isExtraData', hide: true }, + { field: 'isNewColumn', hide: true }, + { field: 'to_field', hide: true }, + // OMIT + { + field: 'omit', + headerName: 'Omit', + cellEditor: 'agCheckboxCellEditor', + editable: true, + width: 70, + onCellValueChanged: validateData, + }, + { + field: 'to_table_name', + 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: 'to_field_display_name', + headerName: 'SEED Header', + cellEditor: AutocompleteCellComponent, + cellEditorParams: (params: ICellRendererParams) => { + return { values: getColumnOptions(params, propertyColumnNames, taxlotColumnNames) } + }, + headerComponent: EditHeaderComponent, + headerComponentParams: { + name: 'SEED Header', + }, + onCellValueChanged: seedHeaderChange, + editable: true, + cellClass: canEditClass, + }, + ] + + const fileCols: ColDef[] = [ + // DATA TYPE: Editable if isExtraData is true + { + field: 'to_data_type', + 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, 'to_data_type', data.isNewColumn) + }, + onCellValueChanged: dataTypeChange, + cellClass: (params) => { + const data = params?.data as { isNewColumn: boolean } + 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: 'from_units', + headerName: 'Units', + headerComponent: EditHeaderComponent, + headerComponentParams: { + name: 'Units', + }, + cellEditor: 'agSelectCellEditor', + cellEditorParams: ({ data }: { data: { to_data_type: string } }) => { + return { + values: unitMap[data.to_data_type] ?? [], + } + }, + cellRenderer: dropdownRenderer, + editable: (params) => { + 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 { to_data_type: string } + return canEdit(data.to_data_type, 'from_units', null) ? canEditClass : '' + }, + }, + { field: 'from_field', 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/step1/constants.ts b/src/app/modules/datasets/data-mappings/step1/constants.ts new file mode 100644 index 00000000..b047d01c --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/constants.ts @@ -0,0 +1,62 @@ +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' }, +} + +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]])) + +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/step1/map-data.component.html b/src/app/modules/datasets/data-mappings/step1/map-data.component.html new file mode 100644 index 00000000..51d6bd11 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.html @@ -0,0 +1,84 @@ +
+ + +
+
Cycle:
+
{{ cycle?.name }}
+
+
+ + +
+
Column Profile:
+ + + @for (profile of columnMappingProfiles; track profile.id) { + {{ profile.name }} + } + + + + + + + + + + + +
+
+ + + +
+ + + Properties + Tax Lots + + +
+ + + +
+
+
+
Editable Cell
+
+ + +
+ @if (errorMessages.length) { + +
    + @for (error of errorMessages; track $index) { +
  • {{ error }}
  • + } +
+
+ } +
+ + +
+ + + 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..5398b9fc --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -0,0 +1,353 @@ +/* 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 { 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' +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, switchMap, take } from 'rxjs' +import { type Column } from '@seed/api/column' +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' +import { AlertComponent, PageComponent } from '@seed/components' +import { ConfigService } from '@seed/services' +import type { ProgressBarObj } from '@seed/services/uploader' +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', + templateUrl: './map-data.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + HelpComponent, + MatButtonModule, + MatButtonToggleModule, + MatDividerModule, + MatIconModule, + MatOptionModule, + MatSidenavModule, + MatSelectModule, + MatStepperModule, + MatTooltipModule, + PageComponent, + ReactiveFormsModule, + FormsModule, + ], +}) +export class MapDataComponent implements OnChanges, OnDestroy { + @Input() orgId: number + @Input() importFile: ImportFile + @Input() columnMappingProfiles: ColumnMappingProfile[] + @Input() cycle: Cycle + @Input() firstFiveRows: Record[] + @Input() mappingSuggestions: MappingSuggestionsResponse + @Input() rawColumnNames: string[] + @Input() matchingPropertyColumns: string[] + @Input() matchingTaxLotColumns: string[] + @Output() completed = new EventEmitter() + @Output() defaultInventoryTypeChange = new EventEmitter() + + private readonly _unsubscribeAll$ = new Subject() + private _configService = inject(ConfigService) + private _columnMappingProfileService = inject(ColumnMappingProfileService) + private _dialog = inject(MatDialog) + private _router = inject(ActivatedRoute) + columnDefs: ColDef[] + dataValid = false + defaultInventoryType: InventoryDisplayType = 'Property' + defaultRow: Record + errorMessages: string[] = [] + fileId = this._router.snapshot.params.id as number + gridApi: GridApi + 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: [], + 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, + to_field: null, + from_units: null, + } + this.setColumnDefs() + this.setRowData() + } + + setColumnDefs() { + this.columnDefs = buildColumnDefs( + this.propertyColumnNames, + this.taxlotColumnNames, + this.importFile.uploaded_filename, + this.seedHeaderChange.bind(this), + this.dataTypeChange.bind(this), + this.validateData.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.defaultInventoryTypeChange.emit(value) + this.gridApi.forEachNode((node) => node.setDataValue('to_table_name', value)) + this.setColumns() + } + + setColumns() { + 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.getColumnMap(node.data)[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() + } + + 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) + this.refreshNode(node) + } + + refreshNode(node: RowNode) { + this.gridApi.refreshCells({ + rowNodes: [node], + force: true, + }) + } + + copyHeadersToSeed() { + const { suggested_column_mappings } = this.mappingSuggestions + const columns = this.getColumns() + const columnMap: Record = columns.reduce((acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), {}) + + this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { + const fileHeader = node.data.from_field + const suggestedColumnName = suggested_column_mappings[fileHeader][1] + const displayName = columnMap[suggestedColumnName] + node.setDataValue('to_field_display_name', displayName) + }) + } + + 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.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 + + 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) + node.setDataValue('to_table_name', toTableMap[mapping.to_table_name]) + }) + } + + 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) + }) + return mappings + } + + 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() { + 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, + mappings: this.getMappingsFromGrid(), + }, + }) + + dialogRef + .afterClosed() + .pipe( + take(1), + switchMap(() => this._columnMappingProfileService.getProfiles(this.orgId, profileTypes)), + ) + .subscribe() + } + + // 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() { + this.errorMessages = [] + const matchingColumns = this.defaultInventoryType === 'Tax Lot' ? this.matchingTaxLotColumns : this.matchingPropertyColumns + let toFields = [] + this.gridApi.forEachNode((node: RowNode) => { + if (node.data.omit) return // skip omitted rows + toFields.push(node.data.to_field) + }) + + // at least one matching column + const hasMatchingCol = toFields.some((col) => matchingColumns.includes(col)) + if (!hasMatchingCol) { + 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}.`) + } + + // 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 + 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.') + } + + this.dataValid = this.errorMessages.length === 0 + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} 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..a4549cf8 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step1/modal/create-profile.component.html @@ -0,0 +1,28 @@ +
+ +
Create Column Mapping Profile
+
+ + + +
+
+ + Name + + @if (form.controls.name?.hasError('valueExists')) { + This name already exists. + } + +
+
+ + +
+ + + + + + +
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..7060627c --- /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 { ColumnMapping, 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; mappings: ColumnMapping[]; 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: this.data.mappings, + } as ColumnMappingProfile + + this._columnMappingProfileService.create(this.data.orgId, this.profile) + .subscribe(() => { + this.close() + }) + } + + close() { + this._dialogRef.close() + } +} 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..06f8c2a7 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.html @@ -0,0 +1,84 @@ +
+
Cycle:
+
{{ cycle?.name }}
+
+ + +
+
+ +
+
+ + +
+
+ + +@if (propertyResults.length) { +
+ + Properties +
+
+
+
Access Level Info
+
+
+ + + + +} +@if (taxlotResults.length) { +
+ + Tax Lots +
+
+
+
Access Level Info
+
+
+ + +} + +@if (loading) { +
+
+ +
Fetching Mapping Results...
+
+ +
+} 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..0df05ba1 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step3/save-mappings.component.ts @@ -0,0 +1,149 @@ +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' +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' +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 { ResultsModalComponent } from 'app/modules/data-quality' +import type { InventoryType } from 'app/modules/inventory' + +@Component({ + selector: 'seed-save-mappings', + templateUrl: './save-mappings.component.html', + imports: [ + AgGridAngular, + CommonModule, + MatButtonModule, + MatDividerModule, + MatIconModule, + MatProgressBarModule, + ], +}) +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() + @Output() backToMapping = new EventEmitter() + + private _configService = inject(ConfigService) + private _dataQualityService = inject(DataQualityService) + private _dialog = inject(MatDialog) + private _uploaderService = inject(UploaderService) + private _unsubscribeAll$ = new Subject() + propertyDefs: ColDef[] = [] + taxlotDefs: ColDef[] = [] + rowData: Record[] = [] + gridTheme$ = this._configService.gridTheme$ + 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 + + this.loading = false + this.propertyResults = this.mappingResultsResponse.properties + this.taxlotResults = this.mappingResultsResponse.tax_lots + + 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, + }) + }), + tap(({ unique_id }) => { this.dqcId = unique_id }), + ) + .subscribe() + } + + setGrid() { + 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(keys: string[]): ColDef[] { + const aliClass = 'bg-primary bg-opacity-25' + + // 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 })) + + const columnDefs = [...hiddenColumnDefs, ...aliColumnDefs, ...inventoryColumnDefs] + return columnDefs + } + + saveData() { + this.completed.emit() + } + + showDataQualityResults() { + this._dialog.open(ResultsModalComponent, { + width: '50rem', + data: { orgId: this.orgId, dqcId: this.dqcId }, + }) + } + + 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..a4c07be5 --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.html @@ -0,0 +1,20 @@ +
+ @if (inProgress) { + + } @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 new file mode 100644 index 00000000..b7a3c6cd --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/match-merge.component.ts @@ -0,0 +1,89 @@ +import { CommonModule } from '@angular/common' +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 { 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 type { InventoryType } from 'app/modules/inventory' +import { ResultsComponent } from './results.component' + +@Component({ + selector: 'seed-match-merge', + templateUrl: './match-merge.component.html', + imports: [ + CommonModule, + MatButtonModule, + ProgressBarComponent, + RouterModule, + ResultsComponent, + ], +}) +export class MatchMergeComponent implements OnDestroy { + @Input() importFileId: number + @Input() orgId: number + @Input() inventoryType: InventoryType + + private _mappingService = inject(MappingService) + private _uploaderService = inject(UploaderService) + private readonly _unsubscribeAll$ = new Subject() + inProgress = true + + progressBarObj = this._uploaderService.defaultProgressBarObj + 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((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 + } + + 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) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} 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..bf78791b --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/results.component.html @@ -0,0 +1,68 @@ +
+ @if (matchingResults) { + + +
+ Records found: {{ matchingResults?.import_file_records }} +
+ } @else { +
+
+ +
Getting Results...
+
+ +
+ } + + @if (hasPropertyData) { +
+ +
+ +
+
+ + Properties +
+ + + +
+ } + + @if (hasTaxlotData) { +
+ +
+ +
+
+ + Tax Lots +
+ + + +
+ } +
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..bf8f81af --- /dev/null +++ b/src/app/modules/datasets/data-mappings/step4/results.component.ts @@ -0,0 +1,108 @@ +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 { + 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.setInventoryGrids() + } + + 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() + } +} 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..2fbf5148 --- /dev/null +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.html @@ -0,0 +1,27 @@ +
+
+
+ +
+ +
+
Upload Property / Tax Lot Data
+
+
+
+ +
+
+ + + 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..59ab7586 --- /dev/null +++ b/src/app/modules/datasets/data-upload/data-upload-modal.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from '@angular/common' +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' + +@Component({ + selector: 'seed-data-upload-modal', + templateUrl: './data-upload-modal.component.html', + imports: [ + CommonModule, + MatDialogModule, + MatDividerModule, + MatIconModule, + PropertyTaxlotUploadComponent, + ], +}) +export class UploadFileModalComponent { + private _dialogRef = inject(MatDialogRef) + + data = inject(MAT_DIALOG_DATA) as { orgId: number; dataset: Dataset; cycles: Cycle[] } + + 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..a8d1677b --- /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) { + + } + + + + +
+ + +
+
+
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..ce060824 --- /dev/null +++ b/src/app/modules/datasets/data-upload/property-taxlot-upload.component.ts @@ -0,0 +1,200 @@ +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.html b/src/app/modules/datasets/dataset/dataset.component.html new file mode 100644 index 00000000..70b1fb8e --- /dev/null +++ b/src/app/modules/datasets/dataset/dataset.component.html @@ -0,0 +1,23 @@ + +
+ @if (importFiles.length) { + + } +
+
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..902f6904 --- /dev/null +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -0,0 +1,162 @@ +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, 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' +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' +import { UserService } from '@seed/api/user' +import { DeleteModalComponent, PageComponent } from '@seed/components' +import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' + +@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 _router = inject(Router) + 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 }), {}) + }), + ) + } + + 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 ` +
+ + Data Mapping + open_in_new + + + Data Pairing + open_in_new + + 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') { + void this._router.navigate(['/data/mappings/', importFile.id]) + } 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) { + 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 new file mode 100644 index 00000000..3eda6111 --- /dev/null +++ b/src/app/modules/datasets/datasets.component.html @@ -0,0 +1,24 @@ + +
+ @if (datasets.length) { + + } +
+
diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts new file mode 100644 index 00000000..451136cf --- /dev/null +++ b/src/app/modules/datasets/datasets.component.ts @@ -0,0 +1,174 @@ +import { CommonModule } from '@angular/common' +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' +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 { 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' +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 { FormModalComponent } from './modal/form-modal.component' + +@Component({ + selector: 'seed-data', + templateUrl: './datasets.component.html', + encapsulation: ViewEncapsulation.None, + imports: [ + AgGridAngular, + CommonModule, + MatButtonModule, + MatIconModule, + PageComponent, + ], +}) +export class DatasetsComponent implements OnDestroy, 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) + private readonly _unsubscribeAll$ = new Subject() + columnDefs: ColDef[] + cycles: Cycle[] = [] + datasets: Dataset[] + datasetsColumns = ['name', 'importfiles', 'updated_at', 'last_modified_by', 'actions'] + existingNames: string[] = [] + gridApi: GridApi + gridTheme$ = this._configService.gridTheme$ + orgId: number + + ngOnInit(): void { + // 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( + tap((orgId) => { + this.orgId = orgId + this._datasetService.list(orgId) + }), + 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() + } + + setColumnDefs() { + 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: 'last_modified_by', headerName: 'Last Modified By' }, + { field: 'actions', headerName: 'Actions', cellRenderer: this.actionsRenderer }, + ] + } + + nameRenderer({ value }: { value: string }) { + return ` +
+ ${value} + open_in_new +
+ ` + } + + actionsRenderer() { + return ` +
+ + add + Data Files + + edit + 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 (!['actions', 'name'].includes(event.colDef.field)) 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') { + 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') { + this.deleteDataset(dataset) + } else if (action === 'detail') { + void this._router.navigate([`/data/${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() + } + + createDataset = () => { + this._dialog.open(FormModalComponent, { + width: '40rem', + data: { orgId: this.orgId, dataset: null, existingNames: this.existingNames }, + }) + } + + trackByFn(_index: number, { id }: Dataset) { + return id + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/data/data.routes.ts b/src/app/modules/datasets/datasets.routes.ts similarity index 52% rename from src/app/modules/data/data.routes.ts rename to src/app/modules/datasets/datasets.routes.ts index 33aa17dc..408068e7 100644 --- a/src/app/modules/data/data.routes.ts +++ b/src/app/modules/datasets/datasets.routes.ts @@ -1,45 +1,45 @@ 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' +import { DataMappingComponent } from './data-mappings' +import { DatasetComponent } from './dataset/dataset.component' +import { DatasetsComponent } from './datasets.component' export default [ { path: '', title: 'Data', - component: DataComponent, + component: DatasetsComponent, runGuardsAndResolvers: 'always', 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$ }, }, }, { path: ':id', - title: 'TODO', - component: DataComponent, + title: 'Dataset Detail', + component: DatasetComponent, resolve: { data: () => { 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$), ) }, }, }, + { + 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 new file mode 100644 index 00000000..944275fc --- /dev/null +++ b/src/app/modules/datasets/index.ts @@ -0,0 +1,3 @@ +export * from './datasets.component' +export * from './dataset' +export * from './data-mappings' diff --git a/src/app/modules/datasets/modal/form-modal.component.html b/src/app/modules/datasets/modal/form-modal.component.html new file mode 100644 index 00000000..36b4eaa5 --- /dev/null +++ b/src/app/modules/datasets/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. + } + +
+ +
+ + + + + + +
diff --git a/src/app/modules/datasets/modal/form-modal.component.ts b/src/app/modules/datasets/modal/form-modal.component.ts new file mode 100644 index 00000000..875d7d1a --- /dev/null +++ b/src/app/modules/datasets/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/detail/grid/building-files-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts index 9db316cb..642e1575 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 @@ -51,7 +51,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 ec8701d4..a5eb6699 100644 --- a/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts @@ -72,8 +72,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 ededb21f..1f8a7bd8 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 @@ -98,7 +98,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 f1c308c1..9e2b939a 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 @@ -73,7 +73,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 fc09dee0..7cf30c0d 100644 --- a/src/app/modules/inventory-detail/meters/meters.component.ts +++ b/src/app/modules/inventory-detail/meters/meters.component.ts @@ -122,7 +122,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( @@ -164,12 +163,9 @@ export class MetersComponent implements OnDestroy, OnInit { ) .subscribe() - this._datasetService - .listDatasets(this.orgId) + this._datasetService.datasets$ .pipe( - tap((datasets) => { - this.datasets = datasets - }), + tap((datasets) => { this.datasets = datasets }), ) .subscribe() } @@ -207,8 +203,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/meters/modal/green-button-upload-modal.component.html b/src/app/modules/inventory-detail/meters/modal/green-button-upload-modal.component.html index 49a2faad..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-detail/notes/notes.component.ts b/src/app/modules/inventory-detail/notes/notes.component.ts index ef4d59df..649e47ee 100644 --- a/src/app/modules/inventory-detail/notes/notes.component.ts +++ b/src/app/modules/inventory-detail/notes/notes.component.ts @@ -145,8 +145,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 cc9bf3d5..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,16 +74,16 @@ export class DataLoggersGridComponent implements OnChanges { actionRenderer() { return `
- + add Sensors - + 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.component.ts b/src/app/modules/inventory-detail/sensors/sensors.component.ts index 0ed36124..dc95c0d2 100644 --- a/src/app/modules/inventory-detail/sensors/sensors.component.ts +++ b/src/app/modules/inventory-detail/sensors/sensors.component.ts @@ -86,17 +86,10 @@ 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.listDatasets(this.orgId)), - tap((datasets) => { - this.datasetId = datasets.length ? datasets[0].id.toString() : null - }), + tap((cycles) => { this.cycleId = cycles.length ? cycles[0].id : null }), + switchMap(() => this._datasetService.datasets$), + tap((datasets) => { this.datasetId = datasets.length ? datasets[0].id.toString() : null }), ) } 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 cad24f99..62b5f6cc 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 @@ -78,8 +78,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 6123569e..17509760 100644 --- a/src/app/modules/inventory-detail/ubids/ubids.component.ts +++ b/src/app/modules/inventory-detail/ubids/ubids.component.ts @@ -117,7 +117,7 @@ export class UbidsComponent implements OnDestroy, OnInit { return `
- check_circle + check_circle
` } @@ -125,8 +125,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 a2f987b0..7280f596 100644 --- a/src/app/modules/inventory-list/groups/groups.component.ts +++ b/src/app/modules/inventory-list/groups/groups.component.ts @@ -87,8 +87,8 @@ export class GroupsComponent implements OnDestroy, OnInit { actionRenderer = () => { return `
- edit - clear + edit + clear
` } 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..cd4bbbf8 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() @@ -53,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...', @@ -70,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 }, + 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) { @@ -95,6 +102,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) }) } @@ -102,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 @@ -113,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/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}` } 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 bbcd59c9..5cedc297 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -155,7 +155,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$, @@ -268,7 +268,13 @@ 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[]) { + this.selectedViewIds = selectedViewIds } onProfileChange(id: number) { 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..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 @@ -1,40 +1,58 @@ -

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

+
+ +
Apply Action to Selection ({{ data.viewIds.length }})
+
+ + + +
+
+
    + @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 3c3dc7ef..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 @@ -1,55 +1,105 @@ 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 { Subject } from 'rxjs' +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatDividerModule } from '@angular/material/divider' +import { MatIconModule } from '@angular/material/icon' +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], + 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: '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: 'Data Quality Check', action: this.tempAction, disabled: !this.data.viewIds.length }, - { name: 'Decode 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.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 }, { 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() { 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(true) } - dismiss() { - this._dialogRef.close() + close(refresh = false) { + this._dialogRef.close(refresh) } ngOnDestroy(): void { 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/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) }, }, { 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 2d36f91e..f08495d5 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
` } diff --git a/src/main.ts b/src/main.ts index bb5c6d90..2166129e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,7 @@ 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' - -ModuleRegistry.registerModules([ClientSideRowModelModule, ColumnAutoSizeModule, EventApiModule, PaginationModule, ValidationModule]) +import 'app/ag-grid-modules' bootstrapApplication(AppComponent, appConfig).catch((err: unknown) => { console.error(err) 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 +}