diff --git a/src/@seed/api/audit-template/audit-template.service.ts b/src/@seed/api/audit-template/audit-template.service.ts new file mode 100644 index 0000000..729ac28 --- /dev/null +++ b/src/@seed/api/audit-template/audit-template.service.ts @@ -0,0 +1,93 @@ +import { HttpClient, type HttpErrorResponse } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { catchError, map, type Observable, ReplaySubject, Subject, takeUntil } from 'rxjs' +import { ErrorService } from '@seed/services/error/error.service' +import { UserService } from '../user' +import type { + AuditTemplateConfig, + AuditTemplateConfigCreateResponse, + AuditTemplateConfigResponse, + AuditTemplateReportType, +} from './audit-template.types' + +@Injectable({ providedIn: 'root' }) +export class AuditTemplateService { + private _httpClient = inject(HttpClient) + private _userService = inject(UserService) + private _errorService = inject(ErrorService) + private readonly _unsubscribeAll$ = new Subject() + private _reportTypes = new ReplaySubject(1) + private _auditTemplateConfig = new ReplaySubject(1) + reportTypes$ = this._reportTypes.asObservable() + auditTemplateConfig$ = this._auditTemplateConfig.asObservable() + + constructor() { + this._reportTypes.next([ + { name: 'ASHRAE Level 2 Report' }, // cspell:disable-line + { name: 'Atlanta Report' }, + { name: 'Baltimore Energy Audit Report' }, + { name: 'Berkeley Report' }, + { name: 'BRICR Phase 0/1' }, + { name: 'Brisbane Energy Audit Report' }, + { name: 'DC BEPS Energy Audit Report' }, // cspell:disable-line + { name: 'DC BEPS RCx Report' }, // cspell:disable-line + { name: 'Demo City Report' }, + { name: 'Denver Energy Audit Report' }, + { name: 'EE-RLF Template' }, + { name: 'Energy Trust of Oregon Report' }, + { name: 'Los Angeles Report' }, + { name: 'Minneapolis Energy Evaluation Report' }, + { name: 'New York City Energy Efficiency Report' }, + { name: 'Office of Recapitalization Energy Audit Report' }, + { name: 'Open Efficiency Report' }, + { name: 'San Francisco Report' }, + { name: 'St. Louis RCx Report' }, + { name: 'St. Louis Report' }, + { name: 'WA Commerce Clean Buildings - Form D Report' }, + { name: 'WA Commerce Grants Report' }, + ]) + this._userService.currentOrganizationId$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organizationId) => { + this.getConfigs(organizationId).subscribe() + }) + } + + getConfigs(org_id: number): Observable { + const url = `/api/v3/audit_template_configs/?organization_id=${org_id}` + return this._httpClient.get(url).pipe( + map((response) => { + this._auditTemplateConfig.next(response.data[0]) + return response.data[0] + }), + catchError((error: HttpErrorResponse) => { + // TODO need to figure out error handling + return this._errorService.handleError(error, 'Error fetching audit template configs') + }), + ) + } + + create(auditTemplateConfig: AuditTemplateConfig): Observable { + const url = `/api/v3/audit_template_configs/?organization_id=${auditTemplateConfig.organization}` + return this._httpClient.post(url, { ...auditTemplateConfig }).pipe( + map((r) => { + this._auditTemplateConfig.next(r.data) + return r.data + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating Audit Template Config') + }), + ) + } + + update(auditTemplateConfig: AuditTemplateConfig): Observable { + const url = `/api/v3/audit_template_configs/${auditTemplateConfig.id}/?organization_id=${auditTemplateConfig.organization}` + return this._httpClient.put(url, { ...auditTemplateConfig }).pipe( + map((r) => { + this._auditTemplateConfig.next(r.data[0]) + return r.data[0] + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating Audit Template Config') + }), + ) + } +} diff --git a/src/@seed/api/audit-template/audit-template.types.ts b/src/@seed/api/audit-template/audit-template.types.ts new file mode 100644 index 0000000..4707a85 --- /dev/null +++ b/src/@seed/api/audit-template/audit-template.types.ts @@ -0,0 +1,22 @@ +export type AuditTemplateReportType = { + name: string; +} + +export type AuditTemplateConfig = { + id: string; + update_at_day: number; + update_at_hour: number; + update_at_minute: number; + last_update_date?: string; + organization: number; +} + +export type AuditTemplateConfigResponse = { + status: string; + data: AuditTemplateConfig[]; +} + +export type AuditTemplateConfigCreateResponse = { + status: string; + data: AuditTemplateConfig; +} diff --git a/src/@seed/api/audit-template/index.ts b/src/@seed/api/audit-template/index.ts new file mode 100644 index 0000000..975acda --- /dev/null +++ b/src/@seed/api/audit-template/index.ts @@ -0,0 +1,2 @@ +export * from './audit-template.service' +export * from './audit-template.types' diff --git a/src/@seed/api/organization/organization.service.ts b/src/@seed/api/organization/organization.service.ts index 2e403be..04dab56 100644 --- a/src/@seed/api/organization/organization.service.ts +++ b/src/@seed/api/organization/organization.service.ts @@ -74,19 +74,24 @@ export class OrganizationService { getOrganizationUsers(orgId: number): void { const url = `/api/v3/organizations/${orgId}/users/` - this._httpClient.get(url) + this._httpClient + .get(url) .pipe( map((response) => response.users.sort((a, b) => naturalSort(a.last_name, b.last_name))), - tap((users) => { this._organizationUsers.next(users) }), + tap((users) => { + this._organizationUsers.next(users) + }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching organization users') }), - ).subscribe() + ) + .subscribe() } getOrganizationAccessLevelTree(orgId: number): void { const url = `/api/v3/organizations/${orgId}/access_levels/tree` - this._httpClient.get(url) + this._httpClient + .get(url) .pipe( map((response) => { // update response to include more usable accessLevelInstancesByDepth @@ -145,9 +150,13 @@ export class OrganizationService { } /* - * Transform access level tree into a more usable format - */ - private _calculateAccessLevelInstancesByDepth(tree: AccessLevelNode[], depth: number, result: AccessLevelsByDepth = {}): AccessLevelsByDepth { + * Transform access level tree into a more usable format + */ + private _calculateAccessLevelInstancesByDepth( + tree: AccessLevelNode[], + depth: number, + result: AccessLevelsByDepth = {}, + ): AccessLevelsByDepth { if (!tree) return result if (!result[depth]) result[depth] = [] for (const ali of tree) { diff --git a/src/app/modules/organizations/settings/audit-template/audit-template.component.html b/src/app/modules/organizations/settings/audit-template/audit-template.component.html index 2c7599d..419675c 100644 --- a/src/app/modules/organizations/settings/audit-template/audit-template.component.html +++ b/src/app/modules/organizations/settings/audit-template/audit-template.component.html @@ -1,10 +1,174 @@ -

Audit Template Settings

-
- If your organization has configured a customized report form in Audit Template, fill out the settings below to enable importing Audit - Template submissions into your SEED organization. -
-
- @if (organization$ | async; as o) { - Organization API Key: {{ o.name }} + + @if (organization) { +
+
+ If your organization has configured a customized report form in Audit Template, fill out the settings below to enable importing + Audit Template submissions into your SEED organization. +
+
+
+
+
Audit Template Credentials
+
+

An API Token, Username and Password are all required to connect to your Audit Template.

+

+ Please refer to the + Audit Template documentation for + more information. +

+
+ + Audit Template Organization Token + + Note, do not prefix the token with "Token ", only include the token itself. + + + Audit Template Email + + + Use the email associated with your account from the + Building Energy Score Site. + + + + Audit Template Password + + + + Use the password associated with your account from the + Building Energy Score Site. + + +
+ +
+
{{ t('Audit Template City ID') }}
+
+

+ Specify your Audit Template City ID. This number is visible in the Audit Template URL when browsing to the 'CITIES' tab. + SEED will import submission data for the specified City only. +

+
+ + {{ t('Audit Template City ID') }} + + +
+ +
+
{{ t('Audit Template Submission Status') }}
+
+

SEED will import data for submissions with the following statuses in Audit Template.

+
+
+ Complies + Pending + Received + Rejected +
+
+ +
+
{{ t('Conditional Import') }}
+
+

+ When this checkbox is checked, SEED will only import Audit Template submissions that have been submitted more recently than + the SEED records' most recent update. If unchecked, all Audit Template submissions will be imported regardless of the + submission date. +

+
+
+ {{ t('Enable Conditional Import') }} +
+
+ +
+
+ +
+
{{ t('Schedule Weekly Update') }}
+ +
+ {{ + t('Enable Audit Template Auto Sync') + }} +
+
+ If you would like to automatically update your SEED organization with Audit Template submission data for the selected Audit + Template City ID, configure the fields below to schedule your weekly update. +
+
+ + {{ t('Day') }} + + @for (d of days; track d.index) { + {{ d.name }} + } + + + + {{ t('Hour') }} (24) + + @for (h of hours; track h) { + {{ h }} + } + + + + {{ t('Minute') }} + + @for (m of minutes; track m) { + {{ m }} + } + + +
+
+ +
+
{{ t('Advanced Settings') }}
+
+

+ {{ + t( + 'If you wish to generate stub Audit Template reports from SEED data, select which Audit Template Report Type SEED should generate.' + ) + }} +

+
+ + + {{ t('Audit Template Report Type') }} + + None + @for (atrt of auditTemplateReportTypes; track atrt.name) { + {{ atrt.name }} + } + + +
+
+ +
+
+
+
} -
+ diff --git a/src/app/modules/organizations/settings/audit-template/audit-template.component.ts b/src/app/modules/organizations/settings/audit-template/audit-template.component.ts index 4a7d124..4f635ae 100644 --- a/src/app/modules/organizations/settings/audit-template/audit-template.component.ts +++ b/src/app/modules/organizations/settings/audit-template/audit-template.component.ts @@ -1,15 +1,168 @@ -import { CommonModule } from '@angular/common' +import { CommonModule, formatDate } from '@angular/common' +import { type OnDestroy, type OnInit } from '@angular/core' import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButton } from '@angular/material/button' +import { MatCheckbox } from '@angular/material/checkbox' +import { MatDivider } from '@angular/material/divider' +import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' -import { OrganizationService } from '@seed/api/organization' +import { MatInputModule } from '@angular/material/input' +import { MatSelectModule } from '@angular/material/select' +import { MatSlideToggleModule } from '@angular/material/slide-toggle' +import { Subject, takeUntil } from 'rxjs' +import { type AuditTemplateConfig, type AuditTemplateReportType, AuditTemplateService } from '@seed/api/audit-template' +import { type Organization, OrganizationService } from '@seed/api/organization' +import { PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' +import { SnackbarService } from 'app/core/snackbar/snackbar.service' @Component({ selector: 'seed-organizations-settings-audit-template', templateUrl: './audit-template.component.html', - imports: [CommonModule, SharedImports, MatIconModule], + imports: [ + CommonModule, + SharedImports, + MatButton, + MatCheckbox, + MatDivider, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + ReactiveFormsModule, + PageComponent, + ], }) -export class AuditTemplateComponent { +export class AuditTemplateComponent implements OnDestroy, OnInit { private _organizationService = inject(OrganizationService) - organization$ = this._organizationService.currentOrganization$ + private _auditTemplateService = inject(AuditTemplateService) + private _snackBar = inject(SnackbarService) + private readonly _unsubscribeAll$ = new Subject() + organization: Organization + auditTemplateConfig: AuditTemplateConfig = { + update_at_day: 0, + update_at_hour: 0, + update_at_minute: 0, + id: null, + organization: null, + } + auditTemplateReportTypes: AuditTemplateReportType[] + auditTemplateForm = new FormGroup({ + at_organization_token: new FormControl(''), + audit_template_user: new FormControl('', [Validators.email]), + audit_template_password: new FormControl(''), + audit_template_city_id: new FormControl(), + status_complies: new FormControl(false), + status_pending: new FormControl(false), + status_received: new FormControl(false), + status_rejected: new FormControl(false), + audit_template_conditional_import: new FormControl(false), + audit_template_sync_enabled: new FormControl(false), + audit_template_report_type: new FormControl(''), + audit_template_config_day: new FormControl(0), + audit_template_config_hour: new FormControl(1), + audit_template_config_minute: new FormControl(1), + }) + status_fields = [ + { key: 'Complies', field: 'status_complies' }, + { key: 'Pending', field: 'status_pending' }, + { key: 'Received', field: 'status_received' }, + { key: 'Rejected', field: 'status_rejected' }, + ] + passwordHidden = true + days = [ + { index: 0, name: 'Sunday' }, + { index: 1, name: 'Monday' }, + { index: 2, name: 'Tuesday' }, + { index: 3, name: 'Wednesday' }, + { index: 4, name: 'Thursday' }, + { index: 5, name: 'Friday' }, + { index: 6, name: 'Saturday' }, + ] + minutes = Array.from(Array(60).keys()) + hours = Array.from(Array(24).keys()) + + ngOnInit(): void { + this._organizationService.currentOrganization$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((organization) => { + this.organization = organization + this.auditTemplateForm.patchValue(this.organization) + for (const field of this.status_fields) { + this.auditTemplateForm.get(field.field).setValue(this.organization.audit_template_status_types.includes(field.key)) + } + if (!this.organization.audit_template_sync_enabled) { + this.auditTemplateForm.get('audit_template_config_day').disable() + this.auditTemplateForm.get('audit_template_config_hour').disable() + this.auditTemplateForm.get('audit_template_config_minute').disable() + } + if (this.auditTemplateConfig) { + this.auditTemplateConfig.organization = organization.id + } + }) + this._auditTemplateService.reportTypes$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((types) => { + this.auditTemplateReportTypes = types + }) + this._auditTemplateService.auditTemplateConfig$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((config) => { + if (config) { + this.auditTemplateConfig = config + } + this.auditTemplateForm.get('audit_template_config_day').setValue(this.auditTemplateConfig.update_at_day) + this.auditTemplateForm.get('audit_template_config_hour').setValue(this.auditTemplateConfig.update_at_hour) + this.auditTemplateForm.get('audit_template_config_minute').setValue(this.auditTemplateConfig.update_at_minute) + }) + } + + updateScheduleInputs(): void { + if (this.auditTemplateForm.get('audit_template_sync_enabled').value) { + this.auditTemplateForm.get('audit_template_config_day').enable() + this.auditTemplateForm.get('audit_template_config_hour').enable() + this.auditTemplateForm.get('audit_template_config_minute').enable() + } else { + this.auditTemplateForm.get('audit_template_config_day').disable() + this.auditTemplateForm.get('audit_template_config_hour').disable() + this.auditTemplateForm.get('audit_template_config_minute').disable() + } + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + togglePassword(): void { + this.passwordHidden = !this.passwordHidden + } + + importSubmissions(): void { + // TODO - Build out import wizard + console.log('Not implemented') + this._snackBar.warning('Not implemented') + } + + submit(): void { + if (this.auditTemplateForm.valid) { + this.organization = { ...this.organization, ...this.auditTemplateForm.value } + + this.organization.audit_template_status_types = this.status_fields + .filter((f) => this.auditTemplateForm.get(f.field).value === true) + .map((f) => f.key) + .join(',') + this._organizationService.updateSettings(this.organization).subscribe() + + if (this.organization.audit_template_sync_enabled) { + this.auditTemplateConfig.update_at_day = this.auditTemplateForm.get('audit_template_config_day').value + this.auditTemplateConfig.update_at_hour = this.auditTemplateForm.get('audit_template_config_hour').value + this.auditTemplateConfig.update_at_minute = this.auditTemplateForm.get('audit_template_config_minute').value + this.auditTemplateConfig.last_update_date = formatDate(new Date(), 'yyyy:MM:dd', 'en-us') + if (!this.auditTemplateConfig.id) { + this._auditTemplateService.create(this.auditTemplateConfig).subscribe((atc) => { + this.auditTemplateConfig = atc + }) + } else { + this._auditTemplateService.update(this.auditTemplateConfig).subscribe((atc) => (this.auditTemplateConfig = atc)) + } + } + } + } } diff --git a/src/app/modules/organizations/settings/settings.component.html b/src/app/modules/organizations/settings/settings.component.html index 078ef55..3c919e7 100644 --- a/src/app/modules/organizations/settings/settings.component.html +++ b/src/app/modules/organizations/settings/settings.component.html @@ -1,7 +1,7 @@ - +