diff --git a/src/@seed/api/geocode/geocode.service.ts b/src/@seed/api/geocode/geocode.service.ts index 5e3fc35e..13490c23 100644 --- a/src/@seed/api/geocode/geocode.service.ts +++ b/src/@seed/api/geocode/geocode.service.ts @@ -13,7 +13,7 @@ export class GeocodeService { private _errorService = inject(ErrorService) geocode(orgId: number, viewIds: number[], type: InventoryType): Observable { - const url = `/api/v3/geocode/geocode_by_ids/&organization_id=${orgId}` + const url = `/api/v3/geocode/geocode_by_ids/?organization_id=${orgId}` const data = { property_view_ids: type === 'taxlots' ? [] : viewIds, taxlot_view_ids: type === 'taxlots' ? viewIds : [], diff --git a/src/@seed/api/geocode/geocode.types.ts b/src/@seed/api/geocode/geocode.types.ts index a9c3206b..6c34d1d5 100644 --- a/src/@seed/api/geocode/geocode.types.ts +++ b/src/@seed/api/geocode/geocode.types.ts @@ -1,6 +1,6 @@ export type ConfidenceSummary = { - properties: InventoryConfidenceSummary; - taxlots: InventoryConfidenceSummary; + properties?: InventoryConfidenceSummary; + taxlots?: InventoryConfidenceSummary; } export type InventoryConfidenceSummary = { diff --git a/src/@seed/api/index.ts b/src/@seed/api/index.ts index a7a17037..54ea4207 100644 --- a/src/@seed/api/index.ts +++ b/src/@seed/api/index.ts @@ -12,6 +12,7 @@ export * from './groups' export * from './inventory' export * from './label' export * from './mapping' +export * from './matching' export * from './meters' export * from './notes' export * from './organization' diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index 35733f00..db5f1a5f 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -397,4 +397,33 @@ export class InventoryService { }), ) } + + propertiesMetersExist(orgId: number, propertyViewIds: number[]): Observable { + const url = `/api/v3/properties/meters_exist/?organization_id=${orgId}` + return this._httpClient.post(url, { property_view_ids: propertyViewIds }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error checking if meters exist for properties') + }), + ) + } + + evaluationExportToCts(orgId: number, viewIds: number[], filename: string): Observable { + const url = `/api/v3/properties/evaluation_export_to_cts/?organization_id=${orgId}` + return this._httpClient.post(url, { filename, property_view_ids: viewIds }, { responseType: 'arraybuffer' }).pipe( + map((response) => new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error exporting to CTS') + }), + ) + } + + facilityBpsExportToCts(orgId: number, viewIds: number[], filename: string): Observable { + const url = `/api/v3/properties/facility_bps_export_to_cts/?organization_id=${orgId}` + return this._httpClient.post(url, { filename, property_view_ids: viewIds }, { responseType: 'arraybuffer' }).pipe( + map((response) => new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error exporting to CTS') + }), + ) + } } diff --git a/src/@seed/api/matching/index.ts b/src/@seed/api/matching/index.ts new file mode 100644 index 00000000..04ba1b9e --- /dev/null +++ b/src/@seed/api/matching/index.ts @@ -0,0 +1,2 @@ +export * from './matching.service' +export * from './matching.types' diff --git a/src/@seed/api/matching/matching.service.ts b/src/@seed/api/matching/matching.service.ts new file mode 100644 index 00000000..05410a5d --- /dev/null +++ b/src/@seed/api/matching/matching.service.ts @@ -0,0 +1,28 @@ +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { catchError, tap } from 'rxjs' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { InventoryType } from 'app/modules/inventory' +import type { MergingResponse } from './matching.types' + +@Injectable({ providedIn: 'root' }) +export class MatchingService { + private _httpClient = inject(HttpClient) + private _errorService = inject(ErrorService) + private _snackBar = inject(SnackBarService) + + mergeInventory(orgId: number, viewIds: number[], type: InventoryType): Observable { + const url = `/api/v3/${type}/merge/?organization_id=${orgId}` + const key = type === 'taxlots' ? 'taxlot_view_ids' : 'property_view_ids' + const data = { [key]: viewIds } + return this._httpClient.post(url, data) + .pipe( + tap(() => { this._snackBar.success('Successfully merged inventory') }), + catchError(() => { + return this._errorService.handleError(null, 'Error merging inventory') + }), + ) + } +} diff --git a/src/@seed/api/matching/matching.types.ts b/src/@seed/api/matching/matching.types.ts new file mode 100644 index 00000000..535b0cb8 --- /dev/null +++ b/src/@seed/api/matching/matching.types.ts @@ -0,0 +1,6 @@ +export type MergingResponse = { + status: string; + match_link_count?: number; + match_merged_count?: number; + message?: string; +} diff --git a/src/@seed/api/organization/organization.service.ts b/src/@seed/api/organization/organization.service.ts index 11ec334f..84f05c60 100644 --- a/src/@seed/api/organization/organization.service.ts +++ b/src/@seed/api/organization/organization.service.ts @@ -28,6 +28,7 @@ import type { OrganizationsResponse, OrganizationUser, OrganizationUserResponse, + OrganizationUserSettings, OrganizationUsersResponse, StartSavingAccessLevelInstancesRequest, UpdateAccessLevelsRequest, @@ -44,6 +45,7 @@ export class OrganizationService { private _organizations = new ReplaySubject(1) private _currentOrganization = new ReplaySubject(1) + private _orgUserSettings = new ReplaySubject(1) private _organizationUsers = new ReplaySubject(1) private _accessLevelTree = new ReplaySubject(1) private _accessLevelInstancesByDepth = new ReplaySubject(1) @@ -51,6 +53,7 @@ export class OrganizationService { organizations$ = this._organizations.asObservable() currentOrganization$ = this._currentOrganization.asObservable() + orgUserSettings$ = this._orgUserSettings.asObservable() organizationUsers$ = this._organizationUsers.asObservable() accessLevelTree$ = this._accessLevelTree.asObservable() accessLevelInstancesByDepth$ = this._accessLevelInstancesByDepth.asObservable() @@ -113,6 +116,7 @@ export class OrganizationService { const data = { settings } const url = `/api/v4/organization_users/${orgUserId}/?organization_id=${orgId}` return this._httpClient.put(url, data).pipe( + tap(({ data }) => { this._orgUserSettings.next(data.settings) }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error updating organization user') }), diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index 9de0b73f..93785d20 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -107,6 +107,7 @@ export type OrganizationUserSettings = { profile?: UserSettingsProfiles; crossCycles?: UserSettingsCrossCycles; labels?: UserLabelSettings; + pins?: UserPinSettings; } type UserSettingsFilters = { @@ -129,6 +130,17 @@ type UserSettingsCrossCycles = { taxlots?: number[]; } +type UserPinSettings = { + properties?: { + left: string[]; + right: string[]; + }; + taxlots?: { + left: string[]; + right: string[]; + }; +} + type UserLabelSettings = { ids: number[]; operator: LabelOperator } export type OrganizationUsersResponse = { diff --git a/src/@seed/api/postoffice/postoffice.service.ts b/src/@seed/api/postoffice/postoffice.service.ts index 7587fea0..686ac7c1 100644 --- a/src/@seed/api/postoffice/postoffice.service.ts +++ b/src/@seed/api/postoffice/postoffice.service.ts @@ -3,7 +3,8 @@ import { inject, Injectable } from '@angular/core' import { catchError, map, type Observable, ReplaySubject, Subject, takeUntil, tap } from 'rxjs' import { UserService } from '@seed/api' import { ErrorService } from '@seed/services' -import type { CreateEmailTemplateResponse, EmailTemplate, ListEmailTemplatesResponse } from './postoffice.types' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { CreateEmailTemplateResponse, EmailTemplate, ListEmailTemplatesResponse, SendEmailResponse } from './postoffice.types' @Injectable({ providedIn: 'root' }) export class PostOfficeService { @@ -11,6 +12,7 @@ export class PostOfficeService { private _userService = inject(UserService) private _errorService = inject(ErrorService) private _emailTemplates = new ReplaySubject() + private _snackBar = inject(SnackBarService) private readonly _unsubscribeAll$ = new Subject() orgId: number emailTemplates$ = this._emailTemplates.asObservable() @@ -66,4 +68,27 @@ export class PostOfficeService { }), ) } + + sendEmail(orgId: number, stateIds: number[], template_id: number, inventory_type: string): Observable { + const url = `/api/v3/postoffice_email/?organization_id=${orgId}` + const data = { + from_email: 'blankl@example.com', // Dummy email. The backend will assign the appropriate email. + template_id, + inventory_id: stateIds, + inventory_type, + } + return this._httpClient.post(url, data) + .pipe( + tap(({ status }) => { + if (status === 'success') { + this._snackBar.success('Successfully sent email') + } else { + this._snackBar.alert('Error sending email') + } + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error sending email') + }), + ) + } } diff --git a/src/@seed/api/postoffice/postoffice.types.ts b/src/@seed/api/postoffice/postoffice.types.ts index 67b8b8d1..02e644ce 100644 --- a/src/@seed/api/postoffice/postoffice.types.ts +++ b/src/@seed/api/postoffice/postoffice.types.ts @@ -20,3 +20,30 @@ export type ListEmailTemplatesResponse = { status: string; data: EmailTemplate[]; } + +export type SendEmailResponse = { + status: string; + data: SentEmailData; +} + +export type SentEmailData = { + backend_alias: string; + bcc: string; + cc: string; + context: string; + created: string; + expires_at: string | null; + from_email: string; + headers: string; + html_message: string; + id: number; + last_updated: string; + message: string; + number_of_retries: number | null; + priority: number; + scheduled_time: string | null; + status: number; + subject: string; + template_id: number; + to: string; +} diff --git a/src/@seed/api/ubid/ubid.service.ts b/src/@seed/api/ubid/ubid.service.ts index c6cda579..35a89471 100644 --- a/src/@seed/api/ubid/ubid.service.ts +++ b/src/@seed/api/ubid/ubid.service.ts @@ -7,7 +7,7 @@ import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { InventoryType, InventoryTypeSingular } from 'app/modules/inventory/inventory.types' import { UserService } from '../user' -import type { Ubid, UbidDetails, UbidResponse, ValidateUbidResponse } from './ubid.types' +import type { DecodeResults, Ubid, UbidDetails, UbidResponse, ValidateUbidResponse } from './ubid.types' @Injectable({ providedIn: 'root' }) export class UbidService { @@ -91,4 +91,54 @@ export class UbidService { }), ) } + + decodeResults(orgId: number, viewIds: number[], type: InventoryType): Observable { + const url = `/api/v3/ubid/decode_results/?organization_id=${orgId}` + const data = { + property_view_ids: type === 'properties' ? viewIds : [], + taxlot_view_ids: type === 'taxlots' ? viewIds : [], + } + return this._httpClient.post(url, data).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching UBID decode results') + }), + ) + } + + decodeByIds(orgId: number, viewIds: number[], type: InventoryType): Observable<{ status: string }> { + const url = `/api/v3/ubid/decode_by_ids/?organization_id=${orgId}` + const data = { + property_view_ids: type === 'properties' ? viewIds : [], + taxlot_view_ids: type === 'taxlots' ? viewIds : [], + } + return this._httpClient.post<{ status: string }>(url, data).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error decoding UBIDs by ID') + }), + ) + } + + compareUbids(orgId: number, ubid1: string, ubid2: string): Observable { + const url = `/api/v3/ubid/get_jaccard_index/?organization_id=${orgId}` + return this._httpClient.post<{ status: string; data: number }>(url, { ubid1, ubid2 }).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error comparing UBIDs') + }), + ) + } + + getUbidModelsByView(orgId: number, viewId: number, type: InventoryType): Observable { + const url = `/api/v3/ubid/ubids_by_view/?organization_id=${orgId}` + const data = { + view_id: viewId, + type: type === 'taxlots' ? 'taxlot' : 'property', + } + return this._httpClient.post<{ status: string; data: Ubid[] }>(url, data).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching UBID models') + }), + ) + } } diff --git a/src/@seed/api/ubid/ubid.types.ts b/src/@seed/api/ubid/ubid.types.ts index 29efe688..4f187b50 100644 --- a/src/@seed/api/ubid/ubid.types.ts +++ b/src/@seed/api/ubid/ubid.types.ts @@ -25,3 +25,9 @@ export type ValidateUbidResponse = { ubid: string; }; } + +export type DecodeResults = { + ubid_not_decoded: number; + ubid_successfully_decoded: number; + ubid_unpopulated: number; +} diff --git a/src/@seed/api/user/user.service.ts b/src/@seed/api/user/user.service.ts index 38314b6a..eb8d3360 100644 --- a/src/@seed/api/user/user.service.ts +++ b/src/@seed/api/user/user.service.ts @@ -194,5 +194,9 @@ export class UserService { userSettings.sorts ??= {} userSettings.sorts.properties ??= [] userSettings.sorts.taxlots ??= [] + + userSettings.pins ??= {} + userSettings.pins.properties ??= { left: [], right: [] } + userSettings.pins.taxlots ??= { left: [], right: [] } } } diff --git a/src/@seed/services/error/error.service.ts b/src/@seed/services/error/error.service.ts index 25fe0e73..ec678661 100644 --- a/src/@seed/services/error/error.service.ts +++ b/src/@seed/services/error/error.service.ts @@ -25,11 +25,14 @@ export class ErrorService { getErrorData(error: HttpErrorResponse, defaultMessage: string) { // Handle different error response structures const err: unknown = error.error + const status = error.status ? `${error.status}: ` : '' const isStr = typeof err === 'string' // If the string is too long (likely html '...'), return the default message - if (isStr && (err.length > 1000 || err.startsWith(''))) return defaultMessage + if (isStr && (err.length > 1000 || err.startsWith(''))) { + return `${status}${defaultMessage}` + } if (isStr) return err const isObj = typeof err === 'object' && err !== null @@ -38,7 +41,7 @@ export class ErrorService { return e.message ?? e.error ?? e.errors ?? null } - return defaultMessage + return `${status}${defaultMessage}` } isObjOfArrayStrings(obj: unknown): obj is Record { diff --git a/src/app/modules/datasets/dataset/dataset.component.ts b/src/app/modules/datasets/dataset/dataset.component.ts index 711cc667..6da34273 100644 --- a/src/app/modules/datasets/dataset/dataset.component.ts +++ b/src/app/modules/datasets/dataset/dataset.component.ts @@ -156,6 +156,8 @@ export class DatasetComponent implements OnDestroy, OnInit { downloadDocument(file: string, filename: string) { const a = document.createElement('a') + // NOTE: downloads failing after a recent change. Requires further investigation + // const url = file.replace('/seed/', '/') const url = file a.href = url a.download = filename diff --git a/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts index 8ace9052..e68ebb0d 100644 --- a/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/building-files-grid.component.ts @@ -39,10 +39,7 @@ export class BuildingFilesGridComponent implements OnInit { setColumnDefs() { this.columnDefs = [ { field: 'file_type', headerName: 'File Type' }, - { - field: 'filename', - headerName: 'File Name', - }, + { field: 'filename', headerName: 'File Name' }, { field: 'created', headerName: 'Created' }, { field: 'actions', headerName: 'Actions', cellRenderer: this.actionRenderer }, ] @@ -71,7 +68,6 @@ export class BuildingFilesGridComponent implements OnInit { downloadDocument(data: unknown) { const { file, filename } = data as { file: string; filename: string } - console.log('Developer Note: Downloads will fail until frontend and backend are on the same server') const a = document.createElement('a') const url = file a.href = url diff --git a/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts index 72078d21..e1716337 100644 --- a/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/documents-grid.component.ts @@ -72,7 +72,7 @@ export class DocumentsGridComponent implements OnChanges, OnDestroy { return `
cloud_download - clear + clear
` } diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.html b/src/app/modules/inventory-list/list/actions/email-modal.component.html new file mode 100644 index 00000000..5d049c83 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.html @@ -0,0 +1,25 @@ + + +
+
+ + Select an Email Template + + @for (template of emailTemplates; track $index) { + {{ template.name }} + } + + + + +
+ +
+ Not seeing your template? + Create one in Organization Settings +
+
diff --git a/src/app/modules/inventory-list/list/actions/email-modal.component.ts b/src/app/modules/inventory-list/list/actions/email-modal.component.ts new file mode 100644 index 00000000..6c15f347 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/email-modal.component.ts @@ -0,0 +1,71 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { RouterModule } from '@angular/router' +import { finalize, Subject, take, tap } from 'rxjs' +import type { EmailTemplate } from '@seed/api' +import { PostOfficeService } from '@seed/api' +import { ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { InventoryType } from 'app/modules/inventory/inventory.types' + +@Component({ + selector: 'seed-email-modal', + templateUrl: './email-modal.component.html', + imports: [ + FormsModule, + MaterialImports, + ModalHeaderComponent, + RouterModule, + ], +}) +export class EmailModalComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _postOfficeService = inject(PostOfficeService) + private _snackBar = inject(SnackBarService) + private _unsubscribeAll$ = new Subject() + + emailTemplates: EmailTemplate[] = [] + selectedTemplateId: number + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + stateIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + this._postOfficeService.getEmailTemplates(this.data.orgId) + .pipe( + tap((emailTemplates) => { + this.emailTemplates = emailTemplates + this.selectedTemplateId = emailTemplates[0]?.id + }), + take(1), + ) + .subscribe() + } + + // selectTemplate() + onSubmit() { + this._postOfficeService.sendEmail(this.data.orgId, this.data.stateIds, this.selectedTemplateId, this.data.type) + .pipe( + take(1), + finalize(() => { + this.close() + }), + ) + .subscribe() + } + + close() { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html new file mode 100644 index 00000000..683be05e --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.html @@ -0,0 +1,35 @@ + + +
+
+ +
+ Select a reporting template to export for the Federal Energy Management Program (FEMP) Energy Independence and Security Act of 2007 + (EISA) Compliance Tracking System (CTS) +
+
+ + + + + CTS Comprehensive Evaluation Upload Template + CTS Facility Upload Template for BPS + + + + Export File Name + + + + @if (inProgress) { + + } +
+ +
+ +
diff --git a/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts new file mode 100644 index 00000000..a92ffee7 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/femp-export-modal.component.ts @@ -0,0 +1,76 @@ +import type { OnDestroy } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { finalize, Subject, tap } from 'rxjs' +import { InventoryService } from '@seed/api' +import { ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { InventoryType } from 'app/modules/inventory/inventory.types' + +@Component({ + selector: 'seed-femp-export-modal', + templateUrl: './femp-export-modal.component.html', + imports: [ + FormsModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + ], +}) +export class FempExportModalComponent implements OnDestroy { + private _dialog = inject(MatDialogRef) + private _inventoryService = inject(InventoryService) + private _snackBar = inject(SnackBarService) + private _unsubscribeAll$ = new Subject() + + exportType: 'evaluation' | 'facility' = 'evaluation' + filename = '' + inProgress = false + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + setExportType(type: 'evaluation' | 'facility'): void { + this.exportType = type + } + + onSubmit() { + this.inProgress = true + const exportRequest = this.exportType === 'evaluation' + ? this._inventoryService.evaluationExportToCts(this.data.orgId, this.data.viewIds, this.filename) + : this._inventoryService.facilityBpsExportToCts(this.data.orgId, this.data.viewIds, this.filename) + + exportRequest + .pipe( + tap((response) => { this.downloadData(response) }), + finalize(() => { + this.inProgress = false + this.close() + }), + ).subscribe() + } + + downloadData(data: Blob) { + const a = document.createElement('a') + const url = URL.createObjectURL(data) + a.href = url + a.download = this.filename + a.click() + URL.revokeObjectURL(url) + this._snackBar.success(`Exported ${this.filename}`) + } + + close(): void { + this._dialog.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html index 225952bf..fe9d4e4b 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.html +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.html @@ -2,32 +2,122 @@
- @if (!hasApiKey) { - -
- {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} -
-
- {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }} -
-
- } + @if (geocodeState === 'verify') { + @if (!hasGeoColumns) { + +
+ {{ t('GEOCODING_COL_COUNT_REQUIREMENT') }} + {{ t('SUGGEST_UPDATE_GEOCODE_COLS') }} +
+
+ } - @if (!geocodingEnabled) { - - {{ t('Geocoding has been disabled for this organization.') }} - + @if (!hasApiKey) { + +
+ {{ t('NO_MAPQUEST_API_KEY_FOR_ORG') }} + {{ t('DIRECTIONS_FOR_UPDATING_MQ_API_KEY') }} +
+
+ } + + @if (!geocodingEnabled) { + + {{ t('Geocoding has been disabled for this organization.') }} + + } + + @if (pSummary?.not_geocoded || tSummary?.not_geocoded) { + + {{ t('NOT_GEOCODED_PREVIOUSLY') }} + @if (pSummary?.not_geocoded) { +
  • {{ pSummary?.not_geocoded }} properties
  • + } + @if (tSummary?.not_geocoded) { +
  • {{ tSummary?.not_geocoded }} tax lots
  • + } +
    + } } - + @if (pMessages || tMessages) { + + @if (geocodeState === 'verify') { +
    + {{ t('GEOCODE_ATTEMPTED_PREVIOUSLY') }} + {{ t('UPDATE_MANUALLY_GEOCODED_INSTRUCTIONS') }} +
    + } + @if (geocodeState === 'result') { +
    {{ t('POST_GEOCODING_COUNTS') }}
    + } - } --> + + @if (pMessages) { +
    Properties
    + } + @if (pSummary?.high_confidence) { +
  • {{ pSummary?.high_confidence }} {{ t('GEOCODED_WITH_HIGH_CONFIDENCE') }}
  • + } + @if (pSummary?.low_confidence) { +
  • {{ pSummary?.low_confidence }} {{ t('GEOCODED_WITH_LOW_CONFIDENCE') }}
  • + } + @if (pSummary?.manual) { +
  • + {{ pSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} + @if (geocodeState === 'verify') { + - {{ t('ITEMS_WILL_NOT_CHANGE') }} + } +
  • + } + @if (pSummary?.missing_address_components) { +
  • {{ pSummary?.missing_address_components }} {{ t('GEOCODE_UNSUCCESSFUL_MISSING_FIELDS') }}
  • + } + + + @if (tMessages) { +
    Tax Lots
    + } + @if (tSummary?.high_confidence) { +
  • {{ tSummary?.high_confidence }} {{ t('GEOCODED_WITH_HIGH_CONFIDENCE') }}
  • + } + @if (tSummary?.low_confidence) { +
  • {{ tSummary?.low_confidence }} {{ t('GEOCODED_WITH_LOW_CONFIDENCE') }}
  • + } + @if (tSummary?.manual) { +
  • + {{ tSummary?.manual }} {{ t('GEOCODED_MANUALLY') }} + @if (geocodeState === 'verify') { + - {{ t('ITEMS_WILL_NOT_CHANGE') }} + } +
  • + } + @if (tSummary?.missing_address_components) { +
  • {{ tSummary?.missing_address_components }} {{ t('GEOCODE_UNSUCCESSFUL_MISSING_FIELDS') }}
  • + } +
    + } + + @if (geocodeState === 'fail') { + + {{ errorMessage }} + + } + + @if (geocodeState === 'geocoding') { + + }
    - + @if (geocodeState === 'verify') { + + } @else if (['result', 'fail'].includes(geocodeState)) { + + }
    diff --git a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts index 992204b2..ce56e02c 100644 --- a/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts +++ b/src/app/modules/inventory-list/list/actions/geocode-modal.component.ts @@ -1,10 +1,11 @@ +import type { HttpErrorResponse } from '@angular/common/http' import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' -import { forkJoin, Subject, tap } from 'rxjs' +import { catchError, EMPTY, forkJoin, Subject, switchMap, tap } from 'rxjs' import { GeocodeService } from '@seed/api' -import type { ConfidenceSummary, GeocodingColumns } from '@seed/api/geocode/geocode.types' -import { AlertComponent, ModalHeaderComponent } from '@seed/components' +import type { ConfidenceSummary, GeocodingColumns, InventoryConfidenceSummary } from '@seed/api/geocode/geocode.types' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' import type { InventoryType } from 'app/modules/inventory/inventory.types' @@ -12,22 +13,40 @@ import type { InventoryType } from 'app/modules/inventory/inventory.types' @Component({ selector: 'seed-geocode-modal', templateUrl: './geocode-modal.component.html', - imports: [AlertComponent, MaterialImports, ModalHeaderComponent, SharedImports], + imports: [AlertComponent, MaterialImports, ModalHeaderComponent, ProgressBarComponent, SharedImports], }) export class GeocodeModalComponent implements OnInit, OnDestroy { private _dialogRef = inject(MatDialogRef) private _geocodeService = inject(GeocodeService) private _unsubscribeAll$ = new Subject() - confidenceSummary: ConfidenceSummary + confidenceSummary: ConfidenceSummary = {} + errorMessage: string geocodingEnabled = true + geoColumns: GeocodingColumns = { + PropertyState: [], + TaxLotState: [], + } hasApiKey = true hasEnoughGeoCols = true hasGeoColumns = true - suggestVerify = true notGeocoded = false + pMessages: boolean + pNotGeocoded: boolean + pSummary: InventoryConfidenceSummary + suggestVerify = true + tMessages: boolean + tNotGeocoded: boolean + tSummary: InventoryConfidenceSummary - geocodeState: 'verify' | 'geocode' | 'result' | 'fail' = 'verify' + geocodeState: 'verify' | 'geocoding' | 'result' | 'fail' = 'verify' + + typeMap = { + verify: 'warning', + geocode: 'primary', + result: 'success', + fail: 'warn', + } data = inject(MAT_DIALOG_DATA) as { orgId: number; @@ -36,7 +55,7 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { } get valid() { - return this.hasApiKey && this.geocodingEnabled && this.hasGeoColumns + return this.geocodeState === 'verify' && this.hasApiKey && this.geocodingEnabled && this.hasGeoColumns } ngOnInit(): void { @@ -65,16 +84,39 @@ export class GeocodeModalComponent implements OnInit, OnDestroy { } processConfidenceSummary(confidenceSummary: ConfidenceSummary) { - this.confidenceSummary = confidenceSummary - // Process the confidence summary as needed + const { properties, taxlots } = confidenceSummary + this.pSummary = properties + this.tSummary = taxlots + + this.pMessages = !!properties && !!(properties.high_confidence || properties.low_confidence || properties.manual || properties.missing_address_components) + this.tMessages = !!taxlots && !!(taxlots.high_confidence || taxlots.low_confidence || taxlots.manual || taxlots.missing_address_components) } close(success = false) { this._dialogRef.close(success) } - onSubmit() { - console.log('submit') + geocodeBuildings() { + this.geocodeState = 'geocoding' + this.processGeoColumns({ PropertyState: [], TaxLotState: [] }) // reset columns + this.processConfidenceSummary({}) // reset confidence summary + + const { orgId, viewIds, type } = this.data + this._geocodeService.geocode(orgId, viewIds, type) + .pipe( + switchMap(() => this._geocodeService.confidenceSummary(this.data.orgId, this.data.viewIds, this.data.type)), + tap((confidenceSummary) => { + this.processConfidenceSummary(confidenceSummary) + this.geocodeState = 'result' + }), + catchError((error: HttpErrorResponse) => { + const defaultMessage = 'An error occurred while geocoding.' + this.geocodeState = 'fail' + this.errorMessage = error.message ?? defaultMessage + return EMPTY + }), + ) + .subscribe() } ngOnDestroy(): void { diff --git a/src/app/modules/inventory-list/list/actions/index.ts b/src/app/modules/inventory-list/list/actions/index.ts index 53534b5c..5bfa75ed 100644 --- a/src/app/modules/inventory-list/list/actions/index.ts +++ b/src/app/modules/inventory-list/list/actions/index.ts @@ -1,3 +1,8 @@ +export * from './email-modal.component' +export * from './femp-export-modal.component' export * from './geocode-modal.component' +export * from './merge-modal.component' export * from './refresh-metadata-modal.component' +export * from './ubid-compare.component' +export * from './ubid-decode.component' export * from './update-derived-data.component' diff --git a/src/app/modules/inventory-list/list/actions/merge-modal.component.html b/src/app/modules/inventory-list/list/actions/merge-modal.component.html new file mode 100644 index 00000000..bbec1df6 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/merge-modal.component.html @@ -0,0 +1,89 @@ + + +
    + @if (status === 'loading') { + + } @else if (status === 'review') { +
    +
    + + Resulting Merge +
    + + + + + +
    + + Selected Inventory +
    +
    + Records will be merged together from bottom to top, with the top record having the highest priority. Drag to reorder. +
    + + + +
    + } @else if (status === 'confirm') { + This action cannot be undone + +
    + +
    + +
      +
    • {{ t('MERGE_IN_CYCLE_MATCHES') }}
    • +
    • {{ t('LINK_OUT_CYCLE_MATCHES') }}
    • +
    • {{ t('MERGE_MULTIPLE_IN_CYCLE_MATCHES') }}
    • +
    + +
    {{ t('LISTING_ORG_MATCHING_CRITERIA') }}
    +
      + @for (column of matchingColumnDisplayNames; track $index) { +
    • {{ column }}
    • + } +
    +
    +
    +
    + } @else if (status === 'complete') { +
    Merge completed successfully!
    + +
      + @for (result of results; track $index) { +
    • {{ result }}
    • + } +
    +
    + } @else if (status === 'error') { + {{ errorMessage }} + } +
    + +
    + @if (status === 'review') { + + } + @if (status === 'confirm') { + + } + @if (status === 'complete' || status === 'error') { + + } +
    diff --git a/src/app/modules/inventory-list/list/actions/merge-modal.component.ts b/src/app/modules/inventory-list/list/actions/merge-modal.component.ts new file mode 100644 index 00000000..9e0aecc0 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/merge-modal.component.ts @@ -0,0 +1,158 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community' +import type { Observable } from 'rxjs' +import { catchError, combineLatest, EMPTY, Subject, take, tap } from 'rxjs' +import type { Column } from '@seed/api' +import { InventoryService, MappableColumnService, MatchingService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import type { FilterResponse, InventoryType, State } from 'app/modules/inventory/inventory.types' + +@Component({ + selector: 'seed-merge-modal', + templateUrl: './merge-modal.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + SharedImports, + ], +}) +export class MergeModalComponent implements OnInit, OnDestroy { + private _configService = inject(ConfigService) + private _dialogRef = inject(MatDialogRef) + private _matchingService = inject(MatchingService) + private _mappableColumnService = inject(MappableColumnService) + private _inventoryService = inject(InventoryService) + private _unsubscribeAll$ = new Subject() + colDefs: ColDef[] = [] + columns: Column[] = [] + errorMessage: string = null + gridHeight = 400 + gridOptions: GridOptions = { rowDragManaged: true } + gridTheme$ = this._configService.gridTheme$ + inventory: FilterResponse + loading: boolean + matchingColumnDisplayNames: string[] = [] + metersExist = false + postData: Record[] = [] + preData: State[] = [] + preGridApi: GridApi + results: string[] = [] + status: 'loading' | 'review' | 'confirm' | 'complete' | 'error' = 'loading' + title = 'Merge Inventory' + + data = inject(MAT_DIALOG_DATA) as { + cycleId: number; + orgId: number; + profileId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + const { orgId, viewIds } = this.data + const metersExist$ = this._inventoryService.propertiesMetersExist(orgId, viewIds) + const columns$ = this.data.type === 'taxlots' ? this._mappableColumnService.getTaxLotColumns(orgId) : this._mappableColumnService.getPropertyColumns(orgId) + const inventory$ = this.getInventory$() + + combineLatest([metersExist$, columns$, inventory$]) + .pipe( + take(1), + tap(([metersExist, columns, inventory]) => { + this.metersExist = metersExist + this.columns = columns + this.matchingColumnDisplayNames = this.columns.filter((c) => c.is_matching_criteria).map((c) => c.display_name) + this.inventory = inventory + this.setGrid() + this.status = 'review' + }), + ) + .subscribe() + } + + getInventory$(): Observable { + const { cycleId, orgId, profileId, viewIds } = this.data + const inventory_type = this.data.type === 'taxlots' ? 'taxlot' : 'property' + const params = new URLSearchParams({ + cycle: cycleId.toString(), + ids_only: 'false', + include_related: 'true', + inventory_type, + organization_id: orgId.toString(), + page: '1', + per_page: '999999999', + }) + + const data = { + include_property_ids: null, + profile_id: profileId, + include_view_ids: viewIds, + } + + return this._inventoryService.getAgInventory(params.toString(), data) + } + + setGrid() { + this.preData = this.inventory.results as State[] + this.gridHeight = Math.min(this.preData.length * 35 + 43, 500) + const dragRow: ColDef = { field: 'Drag', rowDrag: true, resizable: false, width: 70, pinned: 'left' } + this.colDefs = [dragRow, ...this.inventory.column_defs] + this.postData = [this.preData[0]] + } + + onPreGridReady(agGrid: GridReadyEvent) { + this.preGridApi = agGrid.api + } + + onRowDragEnd() { + const firstRow = this.preGridApi.getDisplayedRowAtIndex(0) + this.postData = [firstRow.data] as State[] + } + + onSubmit() { + this.status = 'confirm' + this.title = 'Are you sure you want to continue?' + console.log('are you sure') + } + + onConfirm() { + const { orgId, viewIds, type } = this.data + const singularType = type === 'taxlots' ? 'tax lot' : 'property' + this._matchingService.mergeInventory(orgId, viewIds, type) + .pipe( + tap(({ match_link_count, match_merged_count }) => { + this.results = [ + `Resulting ${singularType} has ${match_link_count} cross cycle links`, + `${match_merged_count} subsequent ${type} merged`, + ] + this.status = 'complete' + this.title = 'Merge Complete' + }), + catchError(({ message }) => { + this.errorMessage = message as string + this.status = 'error' + return EMPTY + }), + ) + .subscribe() + } + + close(success = false): void { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/ubid-compare.component.html b/src/app/modules/inventory-list/list/actions/ubid-compare.component.html new file mode 100644 index 00000000..238de829 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-compare.component.html @@ -0,0 +1,33 @@ + + +
    +
    + The comparison relies on the Jaccard Index, which ranges from 0.0 (no overlap) to 1.0 (perfect match). +
    + +
    + + UBID 1: + + @if (form.controls.ubid1?.hasError('invalid')) { + Invalid UBID + } + + + + UBID 2: + + Invalid UBID + +
    + +
    + @if (result !== null) { + UBID Comparison: {{ result | number: '1.2-2' }} - {{ jaccardQuality(result) }} + } +
    +
    + +
    + +
    diff --git a/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts b/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts new file mode 100644 index 00000000..8c099663 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-compare.component.ts @@ -0,0 +1,117 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { forkJoin, Subject, switchMap, take, tap } from 'rxjs' +import { InventoryService, UbidService } from '@seed/api' +import { ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory' + +@Component({ + selector: 'seed-ubid-compare-modal', + templateUrl: './ubid-compare.component.html', + imports: [ + CommonModule, + FormsModule, + MaterialImports, + ModalHeaderComponent, + ReactiveFormsModule, + ], +}) +export class UbidCompareComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _unsubscribeAll$ = new Subject() + private _ubidService = inject(UbidService) + private _inventoryService = inject(InventoryService) + + ubid1: string + ubid2: string + result: number = null + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + form = new FormGroup({ + ubid1: new FormControl('', [Validators.required]), + ubid2: new FormControl('', [Validators.required]), + }) + + ngOnInit(): void { + this.getUbids() + .pipe( + tap(() => { + this.patchForm() + this.watchForm() + }), + take(1), + ) + .subscribe() + } + + getUbids() { + const { orgId, viewIds, type } = this.data + return forkJoin({ + view1: this._inventoryService.getView(orgId, viewIds[0], type), + view2: this._inventoryService.getView(orgId, viewIds[1], type), + }).pipe( + tap(({ view1, view2 }) => { + this.ubid1 = view1?.state.ubid as string || '' + this.ubid2 = view2?.state.ubid as string || '' + }), + ) + } + + patchForm() { + this.form.patchValue({ + ubid1: this.ubid1, + ubid2: this.ubid2, + }) + } + + watchForm() { + this.watchUbid('ubid1') + this.watchUbid('ubid2') + } + + watchUbid(controlName: string) { + this.form.get(controlName)?.valueChanges + .pipe( + switchMap((value: string) => this._ubidService.validate(this.data.orgId, value)), + tap((result) => { + this.result = null + this.form.get(controlName)?.setErrors(result ? null : { invalid: true }) + }), + ) + .subscribe() + } + + jaccardQuality(jaccard: number) { + if (jaccard <= 0) return 'No Match' + if (jaccard < 0.5) return 'Poor' + return jaccard < 1 ? 'Good' : 'Perfect' + } + + onSubmit() { + const { ubid1, ubid2 } = this.form.value + this._ubidService.compareUbids(this.data.orgId, ubid1, ubid2) + .pipe( + take(1), + tap((result) => { this.result = result }), + ) + .subscribe() + } + + close(): void { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/actions/ubid-decode.component.html b/src/app/modules/inventory-list/list/actions/ubid-decode.component.html new file mode 100644 index 00000000..79c46843 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-decode.component.html @@ -0,0 +1,20 @@ + + +
    + @if (status === 'error') { + {{ errorMessage }} + } @else if (status === 'inProgress') { + + } @else { + + + } +
    + +
    + @if (status === 'review') { + + } @else if (status === 'complete') { + + } +
    diff --git a/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts b/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts new file mode 100644 index 00000000..e8fb1517 --- /dev/null +++ b/src/app/modules/inventory-list/list/actions/ubid-decode.component.ts @@ -0,0 +1,96 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef } from 'ag-grid-community' +import { catchError, EMPTY, filter, Subject, switchMap, take, tap } from 'rxjs' +import { UbidService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import type { InventoryType } from 'app/modules/inventory' + +@Component({ + selector: 'seed-ubid-decode-modal', + templateUrl: './ubid-decode.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + ], +}) +export class UbidDecodeComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _unsubscribeAll$ = new Subject() + private _ubidService = inject(UbidService) + private _configService = inject(ConfigService) + + colDefs: ColDef[] = [ + { field: 'key', flex: 1 }, + { field: 'value', flex: 0.5 }, + ] + errorMessage: string + gridHeight = 175 + gridTheme$ = this._configService.gridTheme$ + rowData: { key: string; value: number }[] + status: 'review' | 'complete' | 'inProgress' | 'error' = 'inProgress' + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + this._ubidService.decodeResults(this.data.orgId, this.data.viewIds, this.data.type) + .pipe( + take(1), + tap((results) => { + this.rowData = [ + { key: 'UBID Not Yet Decoded', value: results.ubid_not_decoded }, + { key: 'UBID Already Decoded - unlikely to change', value: results.ubid_successfully_decoded }, + { key: 'Missing UBID - will be ignored', value: results.ubid_unpopulated }, + ] + this.status = 'review' + }), + ) + .subscribe() + } + + onSubmit() { + this.status = 'inProgress' + this._ubidService.decodeByIds(this.data.orgId, this.data.viewIds, this.data.type) + .pipe( + filter(({ status }) => status === 'success'), + switchMap(() => this._ubidService.decodeResults(this.data.orgId, this.data.viewIds, this.data.type)), + take(1), + tap((results) => { + this.gridHeight = 135 + this.rowData = [ + { key: 'UBID Not Decoded', value: results.ubid_not_decoded }, + { key: 'UBID Successfully Decoded', value: results.ubid_successfully_decoded }, + ] + this.status = 'complete' + }), + catchError(() => { + this.status = 'error' + this.errorMessage = 'An error occurred while decoding UBIDs.' + return EMPTY + }), + ) + .subscribe() + } + + close(success = false): void { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/list/grid/actions.component.html b/src/app/modules/inventory-list/list/grid/actions.component.html index 7efe75a4..95726eb2 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -15,15 +15,14 @@ + - Access Levels @@ -42,12 +41,16 @@ } - - Audit Template -
    - - -
    + @if (type === 'properties') { + + Audit Template +
    + + + + +
    + } @@ -79,13 +82,10 @@ Other - - + + @if (type === 'properties') { + + } @@ -98,10 +98,8 @@ UBID
    - - - - - + + +
    diff --git a/src/app/modules/inventory-list/list/grid/actions.component.ts b/src/app/modules/inventory-list/list/grid/actions.component.ts index 2f9aa687..53b1e7f9 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -9,9 +9,9 @@ import { DeleteModalComponent, MenuItemComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.component' import { DQCStartModalComponent } from 'app/modules/data-quality' -import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent } from 'app/modules/inventory/actions' +import { AliChangeModalComponent, AnalysisRunModalComponent, ExportModalComponent, GroupsModalComponent, LabelsModalComponent, UbidModalComponent } from 'app/modules/inventory/actions' import type { InventoryType, Profile } from '../../../inventory/inventory.types' -import { GeocodeModalComponent, RefreshMetadataModalComponent, UpdateDerivedDataComponent } from '../actions' +import { EmailModalComponent, FempExportModalComponent, GeocodeModalComponent, MergeModalComponent, RefreshMetadataModalComponent, UbidCompareComponent, UbidDecodeComponent, UpdateDerivedDataComponent } from '../actions' @Component({ selector: 'seed-inventory-grid-actions', @@ -25,6 +25,7 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { @Input() orgId: number @Input() profile: Profile @Input() profiles: Profile[] + @Input() selectedStateIds: number[] @Input() selectedViewIds: number[] @Input() type: InventoryType @Output() refreshInventory = new EventEmitter() @@ -102,6 +103,22 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { .subscribe() } + openExportModal() { + const dialogRef = this._dialog.open(ExportModalComponent, { + width: '40rem', + data: { ...this.baseData(), profileId: this.profile?.id || null }, + }) + this.afterClosed(dialogRef) + } + + openMergeModal() { + const dialogRef = this._dialog.open(MergeModalComponent, { + width: '50rem', + data: { ...this.baseData(), cycleId: this.cycleId, profileId: this.profile?.id || null }, + }) + this.afterClosed(dialogRef) + } + openShowPopulatedColumnsModal() { const dialogRef = this._dialog.open(ModalComponent, { width: '40rem', @@ -121,14 +138,6 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } - openExportModal() { - const dialogRef = this._dialog.open(ExportModalComponent, { - width: '40rem', - data: { ...this.baseData(), profileId: this.profile?.id || null }, - }) - this.afterClosed(dialogRef) - } - openAliChangeModal() { const dialogRef = this._dialog.open(AliChangeModalComponent, { width: '40rem', @@ -186,6 +195,26 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openEmailModal() { + const dialogRef = this._dialog.open(EmailModalComponent, { + width: '40rem', + data: { + orgId: this.orgId, + stateIds: this.selectedStateIds, + type: this.type, + }, + }) + this.afterClosed(dialogRef) + } + + openFempExportModal() { + const dialogRef = this._dialog.open(FempExportModalComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + openGeocodeModal() { const dialogRef = this._dialog.open(GeocodeModalComponent, { width: '40rem', @@ -194,6 +223,30 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { this.afterClosed(dialogRef) } + openUbidModal() { + const dialogRef = this._dialog.open(UbidModalComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + + openUbidCompareModal() { + const dialogRef = this._dialog.open(UbidCompareComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + + openUbidDecodeModal() { + const dialogRef = this._dialog.open(UbidDecodeComponent, { + width: '40rem', + data: this.baseData(), + }) + this.afterClosed(dialogRef) + } + afterClosed(dialogRef: MatDialogRef) { dialogRef.afterClosed().pipe( filter(Boolean), diff --git a/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts b/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts index 53d3dede..95a355b8 100644 --- a/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts +++ b/src/app/modules/inventory-list/list/grid/cell-header-menu.component.ts @@ -54,6 +54,7 @@ export class CellHeaderMenuComponent implements IHeaderAngularComp, AfterViewIni this.getScheme() this.setOverlay() this.updateSortState() + this.pinState = this.column.isPinned() this.column.addEventListener('sortChanged', () => { this.updateSortState() }) @@ -129,6 +130,27 @@ export class CellHeaderMenuComponent implements IHeaderAngularComp, AfterViewIni pinCol(direction: 'left' | 'right' | null): void { this.gridApi.setColumnsPinned([this.column], direction) this.detach() + this.updatePins(direction) + } + + updatePins(direction: 'left' | 'right'): void { + const field = this.column.getColDef().field + const pins = this.userSettings.pins[this.type] + const left = new Set(pins.left) + const right = new Set(pins.right) + + // Clear from both sides + left.delete(field) + right.delete(field) + + if (direction === 'left') left.add(field) + if (direction === 'right') right.add(field) + + // Assign back as arrays + pins.left = Array.from(left) + pins.right = Array.from(right) + + this.updateOrgUserSettings() } hideCol() { diff --git a/src/app/modules/inventory-list/list/grid/grid-controls.component.ts b/src/app/modules/inventory-list/list/grid/grid-controls.component.ts index 729de61b..1d7d416e 100644 --- a/src/app/modules/inventory-list/list/grid/grid-controls.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid-controls.component.ts @@ -48,13 +48,10 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { } resetGrid() { - this.resetColumns() + this.resetPins() this.resetFilters() this.resetSorts() - } - - resetColumns() { - this.gridApi.autoSizeAllColumns() + this.updateOrgUser() } resetFilters() { @@ -62,16 +59,18 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { this.userSettings.filters = this.currentUser.settings.filters ?? {} this.userSettings.filters.properties = {} this.userSettings.filters.taxlots = {} - this.updateOrgUser() } resetSorts() { - this.gridApi.applyColumnState({ state: [], applyOrder: true }) - this.gridApi.resetColumnState() this.userSettings.sorts = this.currentUser.settings?.sorts ?? {} this.userSettings.sorts.properties = [] this.userSettings.sorts.taxlots = [] - this.updateOrgUser() + } + + resetPins() { + this.userSettings.pins = this.currentUser.settings?.pins ?? {} + this.userSettings.pins.properties = { left: [], right: [] } + this.userSettings.pins.taxlots = { left: [], right: [] } } updateOrgUser() { @@ -80,9 +79,12 @@ export class InventoryGridControlsComponent implements OnChanges, OnInit { .pipe( take(1), tap(() => { + this.gridApi.resetColumnState() + this.gridApi.applyColumnState({ defaultState: { pinned: null } }) this.gridApi.refreshClientSideRowModel() this.gridApi.refreshCells({ force: true }) this.gridApi.onSortChanged() + this.gridApi.autoSizeAllColumns() }), ) .subscribe() diff --git a/src/app/modules/inventory-list/list/grid/grid.component.ts b/src/app/modules/inventory-list/list/grid/grid.component.ts index e119b380..a8f157cc 100644 --- a/src/app/modules/inventory-list/list/grid/grid.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid.component.ts @@ -120,7 +120,7 @@ export class InventoryGridComponent implements OnChanges { getShortcutColumns(): ColDef[] { const shortcutColumns = [ this.buildInfoCell(), - this.buildShortcutColumn('merged_indicator', 'Merged', 82, 'share'), + this.buildShortcutColumn('merged_indicator', 'Merged', 82, 'merge'), this.buildShortcutColumn('meters_exist_indicator', 'Meters', 78, 'bolt', 'meters'), this.buildShortcutColumn('notes_count', 'Notes', 71, 'mode_comment', 'notes'), this.buildShortcutColumn('groups_indicator', 'Groups', 79, 'G'), diff --git a/src/app/modules/inventory-list/list/inventory.component.html b/src/app/modules/inventory-list/list/inventory.component.html index a3a02815..f36a2eb9 100644 --- a/src/app/modules/inventory-list/list/inventory.component.html +++ b/src/app/modules/inventory-list/list/inventory.component.html @@ -20,6 +20,7 @@ [orgId]="orgId" [profile]="profile" [profiles]="profiles" + [selectedStateIds]="selectedStateIds" [selectedViewIds]="selectedViewIds" [type]="type" (refreshInventory)="refreshInventory$.next()" diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 81d1e512..f328c253 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -18,6 +18,7 @@ import type { InventoryType, Pagination, Profile, + State, } from 'app/modules/inventory' import { ActionsComponent, ConfigSelectorComponent, FilterSortChipsComponent, InventoryGridComponent } from './grid' @@ -71,6 +72,7 @@ export class InventoryComponent implements OnDestroy, OnInit { refreshInventory$ = new Subject() rowData: Record[] selectedViewIds: number[] = [] + selectedStateIds: number[] = [] taxlotProfiles: Profile[] userSettings: OrganizationUserSettings = {} @@ -123,6 +125,12 @@ export class InventoryComponent implements OnDestroy, OnInit { ) .subscribe() + this._organizationService.orgUserSettings$ + .pipe( + tap((settings) => this.userSettings = settings), + ) + .subscribe() + this.refreshInventory$.pipe(switchMap(() => this.refreshInventory())).subscribe() } @@ -207,6 +215,7 @@ export class InventoryComponent implements OnDestroy, OnInit { * returns a null observable to track completion */ loadInventory(): Observable { + this.validateCycleId() if (!this.cycleId) return of(null) const inventory_type = this.type === 'properties' ? 'property' : 'taxlot' const params = new URLSearchParams({ @@ -238,12 +247,20 @@ export class InventoryComponent implements OnDestroy, OnInit { ) as Observable } + validateCycleId() { + if (!this.cycleId) this.cycleId = null + const settingsCycleId = this.userSettings?.cycleId + if (!settingsCycleId) return + if (settingsCycleId !== this.cycleId) this.cycleId = settingsCycleId + } + /* * on initial page load, set any filters and sorts from the user settings */ setFilterSorts() { this.setFilters() this.setSorts() + this.setPins() } onGridReady(gridApi: GridApi) { @@ -251,13 +268,25 @@ export class InventoryComponent implements OnDestroy, OnInit { } onSelectionChanged() { - this.selectedViewIds = this.type === 'taxlots' - ? this.gridApi.getSelectedRows().map(({ taxlot_view_id }: { taxlot_view_id: number }) => taxlot_view_id) - : this.gridApi.getSelectedRows().map(({ property_view_id }: { property_view_id: number }) => property_view_id) + // this.selectedViewIds = this.type === 'taxlots' + // ? this.gridApi.getSelectedRows().map(({ taxlot_view_id }: { taxlot_view_id: number }) => taxlot_view_id) + // : this.gridApi.getSelectedRows().map(({ property_view_id }: { property_view_id: number }) => property_view_id) + + const selectedRows = this.gridApi.getSelectedRows() as State[] + if (this.type === 'taxlots') { + this.selectedViewIds = selectedRows.map((state) => state.taxlot_view_id) + this.selectedStateIds = selectedRows.map((state) => state.taxlot_state_id) + } else { + this.selectedViewIds = selectedRows.map((state) => state.property_view_id) + this.selectedStateIds = selectedRows.map((state) => state.property_state_id) + } } onSelectAll(selectedViewIds: number[]) { this.selectedViewIds = selectedViewIds + this.selectedStateIds = this.type === 'taxlots' + ? this.gridApi.getSelectedRows().map((state: State) => state.taxlot_state_id) + : this.gridApi.getSelectedRows().map((state: State) => state.property_state_id) } onProfileChange(id: number) { @@ -306,6 +335,26 @@ export class InventoryComponent implements OnDestroy, OnInit { this.gridApi.onSortChanged() } + setPins() { + if (!this.userSettings.pins) return + + const { left, right } = this.userSettings.pins[this.type] || {} + + for (const col of left) { + const colDef = this.columnDefs.find((c) => c.field === col) + if (colDef) { + colDef.pinned = 'left' + } + } + + for (const col of right) { + const colDef = this.columnDefs.find((c) => c.field === col) + if (colDef) { + colDef.pinned = 'right' + } + } + } + get sorts() { return this.userSettings.sorts?.[this.type] ?? [] } @@ -318,6 +367,7 @@ export class InventoryComponent implements OnDestroy, OnInit { this.page = 1 this.userSettings.filters[this.type] = filters this.userSettings.sorts[this.type] = sorts + console.log(this.userSettings.pins.properties) this.refreshInventory$.next() } diff --git a/src/app/modules/inventory/actions/index.ts b/src/app/modules/inventory/actions/index.ts index 867ca2c3..e7eb86d0 100644 --- a/src/app/modules/inventory/actions/index.ts +++ b/src/app/modules/inventory/actions/index.ts @@ -4,3 +4,4 @@ export * from './analysis-run-modal.component' export * from './groups-modal.component' export * from './labels-modal.component' export * from './export-modal.component' +export * from './ubid-modal.component' diff --git a/src/app/modules/inventory/actions/ubid-modal.component.html b/src/app/modules/inventory/actions/ubid-modal.component.html new file mode 100644 index 00000000..ef1526e1 --- /dev/null +++ b/src/app/modules/inventory/actions/ubid-modal.component.html @@ -0,0 +1,30 @@ + + +
    + + + + @if (inProgress) { + + } @else if (errMessages.length) { + +
      + @for (msg of errMessages; track $index) { +
    • {{ msg }}
    • + } +
    +
    + } +
    + +
    + + +
    diff --git a/src/app/modules/inventory/actions/ubid-modal.component.ts b/src/app/modules/inventory/actions/ubid-modal.component.ts new file mode 100644 index 00000000..a0fd4f64 --- /dev/null +++ b/src/app/modules/inventory/actions/ubid-modal.component.ts @@ -0,0 +1,249 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellClickedEvent, CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { filter, finalize, forkJoin, map, of, Subject, switchMap, take, tap } from 'rxjs' +import type { Ubid } from '@seed/api' +import { InventoryService, UbidService } from '@seed/api' +import { AlertComponent, ModalHeaderComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import type { InventoryType, InventoryTypeSingular, ViewResponse } from '../inventory.types' + +@Component({ + selector: 'seed-ubid-modal', + templateUrl: './ubid-modal.component.html', + imports: [ + AgGridAngular, + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + ProgressBarComponent, + ], +}) +export class UbidModalComponent implements OnInit, OnDestroy { + private _configService = inject(ConfigService) + private _dialogRef = inject(MatDialogRef) + private _inventoryService = inject(InventoryService) + private _ubidService = inject(UbidService) + private _unsubscribeAll$ = new Subject() + + gridTheme$ = this._configService.gridTheme$ + stateId: number + viewId: number + ubid: string + ubids: Ubid[] = [] + view: ViewResponse + gridApi: GridApi + colDefs: ColDef[] = [] + gridHeight = 0 + gridOptions = { + singleClickEdit: true, + } + ubidsToDelete: number[] = [] + originalUbids: Ubid[] = [] + errMessages: string[] = [] + inProgress = false + singularType: InventoryTypeSingular + + data = inject(MAT_DIALOG_DATA) as { + orgId: number; + viewIds: number[]; + type: InventoryType; + } + + ngOnInit(): void { + this.viewId = this.data.viewIds[0] + this.singularType = this.data.type === 'taxlots' ? 'taxlot' : 'property' + this.getUbids() + } + + getUbids() { + const { orgId, type } = this.data + forkJoin({ + view: this._inventoryService.getView(orgId, this.viewId, type), + ubids: this._ubidService.getUbidModelsByView(orgId, this.viewId, type), + }) + .pipe( + tap(({ view, ubids }) => { + this.view = view + this.stateId = view.state.id + this.ubids = ubids + this.originalUbids = ubids + this.setGrid() + }), + ) + .subscribe() + } + + setGrid() { + // assume all incoming ubids are valid + this.ubids = this.ubids.map((u) => ({ ...u, valid: true })) + this.getGridHeight() + this.colDefs = [ + { + field: 'ubid', + headerName: 'UBID', + flex: 1, + editable: true, + cellRenderer: this.ubidRenderer, + }, + { + field: 'preferred', + headerName: 'Preferred', + flex: 0.5, + editable: true, + onCellValueChanged: this.onPreferredChange, + }, + { + field: 'delete', + headerName: 'Delete', + flex: 0.5, cellRenderer: this.deleteRenderer, + }, + ] + } + + getGridHeight() { + const rowLength = this.ubids.length || 1 + this.gridHeight = Math.min(rowLength * 42 + 50, 400) + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + } + + onCellClicked(event: CellClickedEvent) { + if (event.colDef.field !== 'delete') return + const { id } = event.data as { id: number } + const index = event.rowIndex + + // store id to delete and remove row + if (id) this.ubidsToDelete.push(id) + this.ubids.splice(index, 1) + this.ubids = [...this.ubids] + this.getGridHeight() + } + + ubidRenderer = ({ value }) => { + return ` +
    ${value}
    + ` + } + + deleteRenderer = () => { + return ` +
    + clear +
    + ` + } + + onPreferredChange = (params: CellValueChangedEvent<{ preferred: boolean }>) => { + if (params.newValue) { + params.api.forEachNode((node) => { + if (node.id !== params.node.id && node.data.preferred) { + node.setDataValue('preferred', false) + } + }) + } + } + + addRow() { + const newUbid = { + ubid: '', + preferred: false, + } as Ubid + if (this.data.type === 'taxlots') { + newUbid.taxlot = this.stateId + } else { + newUbid.property = this.stateId + } + this.ubids = [...this.ubids, newUbid] + this.getGridHeight() + } + + onSubmit() { + // stop editing + // clear empty ubids + // delete + // check validity + // update old ones + // create new ones + // save others + this.errMessages = [] + this.gridApi.stopEditing() + this.ubids = this.ubids.filter((u) => u.ubid) + this.inProgress = true + + this.validateNewUbids() + .pipe( + filter(Boolean), + switchMap(() => this.CreateUpdateDeleteUbid()), + finalize(() => { this.inProgress = false }), + ) + .subscribe() + } + + validateNewUbids() { + const originalUbidStrings = this.originalUbids.map((u) => u.ubid) + const ubidsToValidate: string[] = [] + for (const ubid of this.ubids.map((u) => u.ubid)) { + if (!originalUbidStrings.includes(ubid)) { + ubidsToValidate.push(ubid) + } + } + if (!ubidsToValidate.length) return of(true) + + return forkJoin( + ubidsToValidate.map((ubid) => this._ubidService.validate(this.data.orgId, ubid)), + ).pipe( + tap((response) => { + for (const [index, validity] of response.entries()) { + if (!validity) { + this.errMessages.push(`UBID ${ubidsToValidate[index]} is invalid`) + } + } + }), + map((response) => response.every((v) => v)), + take(1), + ) + } + + CreateUpdateDeleteUbid() { + const { orgId, type } = this.data + const createUbids = this.ubids.filter((u) => !u.id) + const updateUbids = this.getUpdateUbids() + + const createDetails = (ubid: Ubid) => ({ ubid: ubid.ubid, preferred: ubid.preferred, [this.singularType]: this.stateId }) + const updateDetails = (ubid: Ubid) => ({ ubid: ubid.ubid, preferred: ubid.preferred }) + + const createRequests = createUbids.map((ubid) => this._ubidService.create(orgId, this.viewId, createDetails(ubid), type)) + const updateRequests = updateUbids.map((ubid) => this._ubidService.update(orgId, this.viewId, ubid.id, updateDetails(ubid), type)) + const deleteRequests = this.ubidsToDelete.map((id) => this._ubidService.delete(this.data.orgId, this.viewId, id, this.data.type)) + + const requests = [...deleteRequests, ...createRequests, ...updateRequests] + + return requests.length ? forkJoin(requests) : of(null) + } + + getUpdateUbids() { + return this.ubids.filter((ubid) => { + if (!ubid.id) return + const oldUbid = this.originalUbids.find((u) => u.id === ubid.id) + return oldUbid ? oldUbid.preferred !== ubid.preferred || oldUbid.ubid !== ubid.ubid : false + }) + } + + close(success = false) { + this._dialogRef.close(success) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory/inventory.types.ts b/src/app/modules/inventory/inventory.types.ts index 2f39f5ed..6a1b6b69 100644 --- a/src/app/modules/inventory/inventory.types.ts +++ b/src/app/modules/inventory/inventory.types.ts @@ -239,8 +239,12 @@ export type State = { files: BuildingFile[]; labels: number[]; measures: Record[]; + property_state_id?: number; + property_view_id?: number; related?: State[]; scenarios: Scenario[]; + taxlot_state_id?: number; + taxlot_view_id?: number; } export type UpdateInventoryResponse = { diff --git a/src/app/modules/organizations/email-templates/email-templates.component.html b/src/app/modules/organizations/email-templates/email-templates.component.html index 445cdfa7..ffa9ed80 100644 --- a/src/app/modules/organizations/email-templates/email-templates.component.html +++ b/src/app/modules/organizations/email-templates/email-templates.component.html @@ -41,7 +41,7 @@

    Custom Emails

    -
    +
    Templates @@ -65,26 +65,35 @@

    Custom Emails

    -
    - - - Subject - - @if (templateForm.controls.subject?.hasError('required')) { - Subject is a required field - } - + @if (!selectedTemplate) { + +
    + +
    + } @else { +
    + + + Subject + + @if (templateForm.controls.subject?.hasError('required')) { + Subject is a required field + } + - Content - - @if (templateForm.controls.html_content?.hasError('required')) { - Content is a required field - } -
    - -
    - -
    + Content + + @if (templateForm.controls.html_content?.hasError('required')) { + Content is a required field + } +
    + +
    + +
    + } diff --git a/src/app/modules/organizations/email-templates/email-templates.component.ts b/src/app/modules/organizations/email-templates/email-templates.component.ts index f841ff95..f268b0c6 100644 --- a/src/app/modules/organizations/email-templates/email-templates.component.ts +++ b/src/app/modules/organizations/email-templates/email-templates.component.ts @@ -6,7 +6,7 @@ import { MatDialog } from '@angular/material/dialog' import { NgxWigModule } from 'ngx-wig' import { filter, map, Subject, switchMap, takeUntil, tap } from 'rxjs' import { type EmailTemplate, PostOfficeService, UserService } from '@seed/api' -import { DeleteModalComponent, PageComponent } from '@seed/components' +import { DeleteModalComponent, NotFoundComponent, PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { naturalSort } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @@ -19,6 +19,7 @@ import { FormModalComponent } from './modal/form-modal.component' CommonModule, MaterialImports, NgxWigModule, + NotFoundComponent, PageComponent, ReactiveFormsModule, ], diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 7d8f6a06..b8064836 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -258,6 +258,17 @@ // } } +.border-button-toggle-group-vertical { + @apply w-fit gap-0 !important; + + .mat-button-toggle { + border: 1px solid rgb(170 170 170) !important; + } + + .mat-button-toggle-checked { + @apply dark:bg-primary dark:bg-opacity-50 !important; + } +} .compact-form { label, span {