diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index 313ec6f2..d7ccc0f3 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -166,6 +166,7 @@ "Associated Building Tax Lot ID": "Associated Building Tax Lot ID", "Associated Tax Lot ID": "Associated Tax Lot ID", "Associated Tax Lot IDs": "Associated Tax Lot IDs", + "At least one of the following Property fields is required: {{fields}}": "At least one of the following Property fields is required: {{fields}}", "Audit Template": "Audit Template", "Audit Template Building ID": "Audit Template Building ID", "Audit Template City ID": "Audit Template City ID", @@ -303,6 +304,7 @@ "Column Profiles": "Column Profiles", "Column Settings": "Column Settings", "Column mappings": "Column mappings", + "Column name": "Column name", "Column representing number of transactions per property.": "Column representing number of transactions per property.", "Column storing the compliance cycle year for each property": "Column storing the compliance cycle year for each property", "Columns": "Columns", @@ -411,6 +413,8 @@ "Create new data set": "Create new data set", "Create new label": "Create new label", "Create new sub-organization": "Create new sub-organization", + "Create new {{type}}": "Create New {{type}}", + "Create {{ali}} {{type}} in Cycle {{cycle}}?": "Create {{ali}} {{type}} in Cycle {{cycle}}?", "Created": "Created", "Cross Cycles": "Cross Cycles", "Cross-Cycles": "Cross-Cycles", @@ -436,6 +440,7 @@ "Cycle Name Benchmark Field": "Cycle Name Benchmark Field", "Cycle Selection": "Cycle Selection", "Cycle Start": "Cycle Start", + "Cycle is required": "Cycle is required", "Cycle selection and goal details can be customized by clicking the Configure Goals button below.": "Cycle selection and goal details can be customized by clicking the Configure Goals button below.", "Cycle updated.": "Cycle updated.", "Cycle used as the baseline for calculations": "Cycle used as the baseline for calculations", @@ -680,6 +685,7 @@ "Exported": "Exported", "Exporting properties to your Audit Template account": "Exporting properties to your Audit Template account", "Exporting selected properties...": "Exporting selected properties...", + "Extra Data": "Extra Data", "Extra columns to display alongside the calculated columns": "Extra columns to display alongside the calculated columns", "FACILITIES_PLAN_INTRO": "Use the settings below to configure a Facilities Plan to identify facilities that account for a specified percentage of the organization's overall energy use. Originally developed to help identify covered facilities for Section 432 of the Energy Independence and Security Act of 2007 (EISA 432), but can be used for other planning purposes. This section contains the core required settings. Additional configurations can be made per access level or cycle in the Facilities Plan tab of the Insights section.", "FAILED_GEOCODE_INVALID_MAPQUEST_API_KEY": "Geocoding failed - Your MapQuest API Key is invalid. Update your MapQuest API Key or disable geocoding in Organization Settings.", @@ -1340,6 +1346,7 @@ "Remove User from Organizatio": "Remove User from Organizatio", "Remove User from Organization": "Remove User from Organization", "Remove buildings from project": "Remove buildings from project", + "Remove column": "Remove column", "Remove inventory and organizations": "Remove inventory and organizations", "Removing buildings from project": "Removing buildings from project", "Rename": "Rename", diff --git a/public/i18n/es.json b/public/i18n/es.json index 01b7772d..62a71e85 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -166,6 +166,7 @@ "Associated Building Tax Lot ID": "Número de identificación fiscal del edificio asociado", "Associated Tax Lot ID": "ID de parcela fiscal asociada", "Associated Tax Lot IDs": "CIF asociados", + "At least one of the following Property fields is required: {{fields}}": "Se requiere al menos uno de los siguientes campos de propiedad: {{fields}}", "Audit Template": "Plantilla de auditoría", "Audit Template Building ID": "Plantilla de auditoría ID del edificio", "Audit Template City ID": "Plantilla de auditoría ID de ciudad", @@ -303,6 +304,7 @@ "Column Profiles": "Perfiles de columna", "Column Settings": "Ajustes de columna", "Column mappings": "Mapeos de columnas", + "Column name": "Nombre de la columna", "Column representing number of transactions per property.": "Columna que representa el número de transacciones por propiedad.", "Column storing the compliance cycle year for each property": "Columna que almacena el año del ciclo de cumplimiento para cada propiedad.", "Columns": "Columnas", @@ -411,6 +413,8 @@ "Create new data set": "Crear un nuevo conjunto de datos", "Create new label": "Crear nueva etiqueta", "Create new sub-organization": "Crear una nueva suborganización", + "Create new {{type}}": "Crear Nuevo {{type}}", + "Create {{ali}} {{type}} in Cycle {{cycle}}?": "Crear {{ali}} {{type}} en ciclo {{cycle}}¿", "Created": "Creado", "Cross Cycles": "Ciclos cruzados", "Cross-Cycles": "Ciclos cruzados", @@ -436,6 +440,7 @@ "Cycle Name Benchmark Field": "Campo de referencia del nombre del ciclo", "Cycle Selection": "Selección de ciclos", "Cycle Start": "Inicio del ciclo", + "Cycle is required": "Se requiere ciclo", "Cycle selection and goal details can be customized by clicking the Configure Goals button below.": "La selección del ciclo y los detalles del objetivo se pueden personalizar haciendo clic en el botón Configurar objetivos que aparece a continuación.", "Cycle updated.": "Ciclo actualizado.", "Cycle used as the baseline for calculations": "Ciclo utilizado como base para los cálculos.", @@ -680,6 +685,7 @@ "Exported": "Exportado", "Exporting properties to your Audit Template account": "Exportación de propiedades a su cuenta de Plantilla de Auditoría", "Exporting selected properties...": "Exportar propiedades seleccionadas...", + "Extra Data": "Datos adicionales", "Extra columns to display alongside the calculated columns": "Columnas adicionales para mostrar junto a las columnas calculadas.", "FACILITIES_PLAN_INTRO": "Utilice la configuración que se muestra a continuación para configurar un Plan de Instalaciones que identifique aquellas instalaciones que representan un porcentaje específico del consumo energético total de la organización. Originalmente desarrollado para ayudar a identificar las instalaciones cubiertas por la Sección 432 de la Ley de Independencia y Seguridad Energética de 2007 (EISA 432), puede utilizarse para otros fines de planificación. Esta sección contiene la configuración básica obligatoria. Se pueden realizar configuraciones adicionales por nivel de acceso o ciclo en la pestaña Plan de Instalaciones de la sección Información.", "FAILED_GEOCODE_INVALID_MAPQUEST_API_KEY": "Error de geocodificación - Su clave API de MapQuest no es válida. Actualice su clave API de MapQuest o desactive la geocodificación en los ajustes de la organización.", @@ -1340,6 +1346,7 @@ "Remove User from Organizatio": "Eliminar usuario de la organización", "Remove User from Organization": "Eliminar usuario de la organización", "Remove buildings from project": "Retirar edificios del proyecto", + "Remove column": "Eliminar columna", "Remove inventory and organizations": "Eliminar inventario y organizaciones", "Removing buildings from project": "Retirada de edificios del proyecto", "Rename": "Renombrar", diff --git a/public/i18n/fr_CA.json b/public/i18n/fr_CA.json index abbc1574..762da03c 100644 --- a/public/i18n/fr_CA.json +++ b/public/i18n/fr_CA.json @@ -166,6 +166,7 @@ "Associated Building Tax Lot ID": "ID de lot d'impôt du bâtiment associé", "Associated Tax Lot ID": "ID de lot d'impôt associé", "Associated Tax Lot IDs": "IDs des lots d'impôt associé", + "At least one of the following Property fields is required: {{fields}}": "Au moins un des champs de propriété suivants est obligatoire : {{fields}}", "Audit Template": "Modèle d'audit", "Audit Template Building ID": "Audit Template ID de bâtiment", "Audit Template City ID": "Modèle d'audit ID de ville", @@ -303,6 +304,7 @@ "Column Profiles": "Profils de colonnes", "Column Settings": "Paramètres de colonne", "Column mappings": "Mappages de colonnes", + "Column name": "Nom de la colonne", "Column representing number of transactions per property.": "Colonne représentant le nombre de transactions par propriété.", "Column storing the compliance cycle year for each property": "Colonne indiquant l'année du cycle de conformité pour chaque propriété", "Columns": "Colonnes", @@ -411,6 +413,8 @@ "Create new data set": "Créer un nouveau jeu de données", "Create new label": "Créer une nouvelle étiquette", "Create new sub-organization": "Créer une nouvelle sous-organisation", + "Create new {{type}}": "Créer Un(e) {{type}}", + "Create {{ali}} {{type}} in Cycle {{cycle}}?": "Créer {{ali}} {{type}} en cycle {{cycle}}?", "Created": "Créé", "Cross Cycles": "Cross Cycles", "Cross-Cycles": "Entre les Cycles", @@ -436,6 +440,7 @@ "Cycle Name Benchmark Field": "Nom du cycle Champ de référence", "Cycle Selection": "Sélection des cycles", "Cycle Start": "Démarrage du cycle", + "Cycle is required": "Le vélo est nécessaire", "Cycle selection and goal details can be customized by clicking the Configure Goals button below.": "La sélection des cycles et les détails des objectifs peuvent être personnalisés en cliquant sur le bouton Configurer les objectifs ci-dessous.", "Cycle updated.": "Cycle mis à jour.", "Cycle used as the baseline for calculations": "Cycle utilisé comme référence pour les calculs", @@ -680,6 +685,7 @@ "Exported": "Exporté", "Exporting properties to your Audit Template account": "Exporter des propriétés vers votre compte de modèle d'audit", "Exporting selected properties...": "Exportation des propriétés sélectionnées...", + "Extra Data": "Données supplémentaires", "Extra columns to display alongside the calculated columns": "Colonnes supplémentaires à afficher à côté des colonnes calculées", "FACILITIES_PLAN_INTRO": "Utilisez les paramètres ci-dessous pour configurer un plan d'infrastructures afin d'identifier les installations qui représentent un pourcentage spécifié de la consommation énergétique totale de l'organisation. Initialement conçu pour identifier les installations concernées par l'article 432 de la loi de 2007 sur l'indépendance et la sécurité énergétiques (EISA 432), cet outil peut également servir à d'autres fins de planification. Cette section présente les paramètres de base obligatoires. Des configurations supplémentaires peuvent être effectuées par niveau d'accès ou par cycle dans l'onglet « Plan d'infrastructures » de la section « Informations ».", "FAILED_GEOCODE_INVALID_MAPQUEST_API_KEY": "Échec du géocodage : votre clé API MapQuest n'est pas valide. Mettez à jour votre clé API MapQuest ou désactivez le géocodage dans les paramètres de l'organisation.", @@ -1340,6 +1346,7 @@ "Remove User from Organizatio": "Supprimer l'utilisateur de l'organisation", "Remove User from Organization": "Supprimer l'utilisateur de l'organisation", "Remove buildings from project": "Supprimer les bâtiments du projet", + "Remove column": "Supprimer la colonne", "Remove inventory and organizations": "Supprimer l'inventaire et les organisations", "Removing buildings from project": "Retrait des bâtiments du projet", "Rename": "Renommer", diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index d7c61dd7..d82390bf 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -294,12 +294,17 @@ export class InventoryService { inventoryType: InventoryType, viewId: number | null = null, ): Observable { - const url = `/api/v3/${inventoryType}/form_create/` + const url = `/api/v3/${inventoryType}/` const params: Record = { organization_id: this.orgId } if (viewId !== null) { params.related_view_id = viewId } - return this._httpClient.post(url, data, { params }).pipe( + return this._httpClient.post>(url, data, { params }).pipe( + map((r) => ({ + status: r.status as string, + // normalize property_view_id / taxlot_view_id to view_id + view_id: (r.property_view_id ?? r.taxlot_view_id ?? r.view_id ?? null) as number | null, + })), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, `Error creating ${inventoryType === 'taxlots' ? 'tax lot' : 'property'}`) }), diff --git a/src/app/modules/inventory-detail/timeline/timeline.component.html b/src/app/modules/inventory-detail/timeline/timeline.component.html index ff08aad2..2c08426a 100644 --- a/src/app/modules/inventory-detail/timeline/timeline.component.html +++ b/src/app/modules/inventory-detail/timeline/timeline.component.html @@ -1,11 +1,120 @@ - +
+ +
+
{{ timeline.length }} cycle(s)
+
+ Sort: + +
+
+ + @if (loading) { + + } @else if (!timeline.length) { +
No timeline events found.
+ } @else { + + @for (cycle of timeline; track cycle.cycle) { + + + {{ cycleNameById[cycle.cycle] ?? 'Cycle ' + cycle.cycle }} + {{ cycle.events.length }} event(s) + + +
+ @for (event of cycle.events; track event.id) { + + + + + {{ eventLabel[event.event_type] }} + {{ event.modified | date: 'yyyy-MM-dd HH:mm' }} + @if (userName(event.user_id)) { + {{ userName(event.user_id) }} + } + @if (event.audit_date) { + Audit: {{ event.audit_date }} + } + @if (event.analysis) { + {{ event.analysis.service }} + } + + + + + @if (event.event_type === 'NoteEvent' && event.note) { +
+
{{ event.note.updated | date: 'yyyy-MM-dd HH:mm' }}
+

{{ event.note.text }}

+
+ } + + + @if (event.event_type === 'AnalysisEvent' && event.analysis) { +
+
+
Name
+
{{ event.analysis.name }}
+
Service
+
{{ event.analysis.service }}
+
Created
+
{{ event.analysis.created_at | date: 'yyyy-MM-dd' }}
+ @if (formatDuration(event.analysis.start_time, event.analysis.end_time)) { +
Duration
+
{{ formatDuration(event.analysis.start_time, event.analysis.end_time) }}
+ } + @if (event.analysis.status) { +
Status
+
{{ event.analysis.status }}
+ } +
+
+ } + + + @if (event.event_type === 'ATEvent' && event.scenarios?.length) { + + @for (scenario of event.scenarios; track scenario.id) { + + + {{ scenario.name }} + + Elec: {{ scenario.annual_electricity_savings ?? 'N/A' }} kBtu + {{ scenario.measures?.length ?? 0 }} measure(s) + + + @if (scenario.measures?.length) { +
+ @for (measure of scenario.measures; track $index) { +
+ {{ measure['category'] }} + {{ measure['display_name'] }} + {{ measure['implementation_status'] }} +
+ } +
+ } @else { +
No measures.
+ } +
+ } +
+ } +
+ } +
+
+ } + } +
diff --git a/src/app/modules/inventory-detail/timeline/timeline.component.ts b/src/app/modules/inventory-detail/timeline/timeline.component.ts index 50dfb24e..bb9a0295 100644 --- a/src/app/modules/inventory-detail/timeline/timeline.component.ts +++ b/src/app/modules/inventory-detail/timeline/timeline.component.ts @@ -1,37 +1,134 @@ import { CommonModule } from '@angular/common' +import { HttpClient } from '@angular/common/http' import type { OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import type { ColDef, GridApi } from 'ag-grid-community' -import type { Observable } from 'rxjs' -import { tap } from 'rxjs' -import { OrganizationService } from '@seed/api' -import { NotFoundComponent, PageComponent } from '@seed/components' +import { forkJoin, take } from 'rxjs' +import type { Cycle, OrganizationUser, Scenario } from '@seed/api' +import { CycleService, InventoryService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory/inventory.types' +import type { TimelineCycle, TimelineEvent, TimelineEventsResponse } from './timeline.types' @Component({ selector: 'seed-inventory-detail-timeline', templateUrl: './timeline.component.html', - imports: [CommonModule, NotFoundComponent, PageComponent], + imports: [CommonModule, MaterialImports, PageComponent], }) export class TimelineComponent implements OnInit { + private _cycleService = inject(CycleService) + private _httpClient = inject(HttpClient) + private _inventoryService = inject(InventoryService) private _organizationService = inject(OrganizationService) private _route = inject(ActivatedRoute) - columnDefs: ColDef[] - rowData: Record[] = [] - gridApi: GridApi + + cycles: Cycle[] = [] + cycleNameById: Record = {} + loading = true + orgId: number + orgUsers: OrganizationUser[] = [] + propertyId: number + sortDesc = false + timeline: TimelineCycle[] = [] + type: InventoryType viewId: number - viewDisplayField$: Observable - ngOnInit() { - this.getUrlParams().subscribe() + readonly eventLabel: Record = { + NoteEvent: 'Note', + AnalysisEvent: 'Analysis', + ATEvent: 'Audit Template Upload', + } + + readonly eventIcon: Record = { + NoteEvent: 'fa-solid:note-sticky', + AnalysisEvent: 'fa-solid:chart-line', + ATEvent: 'fa-solid:file-import', + } + + ngOnInit(): void { + const params = this._route.parent?.snapshot.paramMap + this.viewId = parseInt(params?.get('id') ?? '0') + this.type = (params?.get('type') as InventoryType) ?? 'properties' + + this._organizationService.currentOrganization$.pipe().subscribe((org) => { + this.orgId = org.org_id + this.loadData() + }) } - getUrlParams() { - return this._route.parent.paramMap.pipe( - tap((params) => { - this.viewId = parseInt(params.get('id')) - this.viewDisplayField$ = this._organizationService.getViewDisplayField(this.viewId, 'properties') - }), + loadData(): void { + this.loading = true + forkJoin({ + view: this._inventoryService.getView(this.orgId, this.viewId, this.type), + cycles: this._cycleService.cycles$.pipe(take(1)), + users: this._organizationService.getOrganizationUsers(this.orgId), + }).subscribe(({ view, cycles, users }) => { + const inventoryObj = this.type === 'taxlots' ? view.taxlot : view.property + this.propertyId = inventoryObj?.id + this.cycles = cycles + this.cycleNameById = cycles.reduce>((acc, c) => ({ ...acc, [c.id]: c.name }), {}) + this.orgUsers = users + + this._httpClient + .get(`/api/v3/${this.type}/${this.propertyId}/events/`, { + params: { organization_id: this.orgId }, + }) + .subscribe((response) => { + this.buildTimeline(response.data ?? []) + this.loading = false + }) + }) + } + + buildTimeline(events: TimelineEvent[]): void { + events.sort((a, b) => + this.sortDesc + ? new Date(a.modified).getTime() - new Date(b.modified).getTime() + : new Date(b.modified).getTime() - new Date(a.modified).getTime(), + ) + + const byCycle: Record = {} + for (const event of events) { + if (!byCycle[event.cycle]) { + byCycle[event.cycle] = { cycle: event.cycle, cycle_end_date: event.cycle_end_date, events: [] } + } + byCycle[event.cycle].events.push(event) + } + + this.timeline = Object.values(byCycle).sort((a, b) => + this.sortDesc + ? new Date(a.cycle_end_date).getTime() - new Date(b.cycle_end_date).getTime() + : new Date(b.cycle_end_date).getTime() - new Date(a.cycle_end_date).getTime(), ) } + + toggleSort(): void { + this.sortDesc = !this.sortDesc + this.buildTimeline(this.timeline.flatMap((c) => c.events)) + } + + userName(userId: number | null): string { + if (!userId) return '' + const user = this.orgUsers.find((u) => u.user_id === userId) + if (!user) return '' + return user.first_name && user.last_name ? `${user.first_name} ${user.last_name}` : user.email + } + + formatDuration(start: string | null, end: string | null): string { + if (!start || !end) return '' + const ms = new Date(end).getTime() - new Date(start).getTime() + const s = Math.floor(ms / 1000) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + const d = Math.floor(h / 24) + if (d) return `${d} days` + if (h) return `${h} hours` + if (m) return `${m} minutes` + return `${s} seconds` + } + + hasMeasures(scenarios: Scenario[]): boolean { + return scenarios?.some((s) => s.measures?.length > 0) + } } diff --git a/src/app/modules/inventory-detail/timeline/timeline.types.ts b/src/app/modules/inventory-detail/timeline/timeline.types.ts new file mode 100644 index 00000000..ae79e2ba --- /dev/null +++ b/src/app/modules/inventory-detail/timeline/timeline.types.ts @@ -0,0 +1,42 @@ +import type { Scenario } from '@seed/api' + +export type TimelineEventType = 'NoteEvent' | 'AnalysisEvent' | 'ATEvent' + +export type TimelineNote = { + id: number; + text: string; + updated: string; +} + +export type TimelineAnalysis = { + name: string; + service: string; + created_at: string; + start_time: string | null; + end_time: string | null; + status: string; +} + +export type TimelineEvent = { + id: number; + cycle: number; + cycle_end_date: string; + modified: string; + user_id: number | null; + event_type: TimelineEventType; + audit_date?: string; + note?: TimelineNote; + analysis?: TimelineAnalysis; + scenarios?: Scenario[]; +} + +export type TimelineEventsResponse = { + status: string; + data: TimelineEvent[]; +} + +export type TimelineCycle = { + cycle: number; + cycle_end_date: string; + events: TimelineEvent[]; +} 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 610a5786..e41253f7 100644 --- a/src/app/modules/inventory-list/list/grid/grid.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid.component.ts @@ -81,6 +81,7 @@ export class InventoryGridComponent implements OnChanges { checkboxes: true, headerCheckbox: true, }, + selectionColumnDef: { pinned: 'left', lockPinned: true, lockPosition: 'left' }, rowClassRules: { 'even-row': (params) => params.node.rowIndex % 2 === 0, }, @@ -145,6 +146,8 @@ export class InventoryGridComponent implements OnChanges { return names.map((name) => ({ field: name, headerName: name, + // Pinned left so user-pinned columns appear to the right of ALI columns + pinned: 'left' as const, hide: !this.showAccessLevelInstances, filter: true, sortable: false, @@ -180,7 +183,8 @@ export class InventoryGridComponent implements OnChanges { this.buildShortcutColumn('groups_indicator', 'Groups', 44, 'workspaces'), this.buildLabelsCell(), ] - return shortcutColumns + // Pin shortcuts so user-pinned columns land after them in the pinned area + return shortcutColumns.map((c) => ({ ...c, pinned: 'left' as const })) } addHeaderMenu() { diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index b4e28f69..07de76b3 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -314,6 +314,7 @@ export class InventoryComponent implements OnDestroy, OnInit { this.rowData = results }), map(() => null), + catchError(() => of(null)), ) }), ) as Observable diff --git a/src/app/modules/inventory/create/inventory-create.component.ts b/src/app/modules/inventory/create/inventory-create.component.ts index c7d78783..8d1bac0c 100644 --- a/src/app/modules/inventory/create/inventory-create.component.ts +++ b/src/app/modules/inventory/create/inventory-create.component.ts @@ -388,8 +388,8 @@ export class InventoryCreateComponent implements OnInit, OnDestroy { } const config = { - access_level_instance: this.configForm.get('access_level_instance').value, - cycle: this.configForm.get('cycle').value, + access_level_instance_id: this.configForm.get('access_level_instance').value, + cycle_id: this.configForm.get('cycle').value, } const relatedState = data[this.related].state diff --git a/src/app/modules/inventory/inventory.types.ts b/src/app/modules/inventory/inventory.types.ts index d1c5ddb5..a72630a2 100644 --- a/src/app/modules/inventory/inventory.types.ts +++ b/src/app/modules/inventory/inventory.types.ts @@ -261,8 +261,8 @@ export type InventoryFormCreateState = { } export type InventoryFormCreateData = { - access_level_instance: number; - cycle: number; + access_level_instance_id: number; + cycle_id: number; state: InventoryFormCreateState; }