diff --git a/.spelling.dic b/.spelling.dic index e5e47e32..fd9d6025 100644 --- a/.spelling.dic +++ b/.spelling.dic @@ -78,3 +78,5 @@ unrs unsubscription xmark csrftoken +sankey +evse diff --git a/package.json b/package.json index 8ff3be91..996117a0 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "ag-grid-angular": "^35.2.0", "ag-grid-community": "^35.2.0", "chart.js": "^4.5.1", + "chartjs-chart-sankey": "^0.14.0", "chartjs-plugin-annotation": "^3.1.0", "chartjs-plugin-zoom": "^2.2.0", "crypto-es": "^3.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a39cb1c4..9e385b47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + chartjs-chart-sankey: + specifier: ^0.14.0 + version: 0.14.0(chart.js@4.5.1) chartjs-plugin-annotation: specifier: ^3.1.0 version: 3.1.0(chart.js@4.5.1) @@ -2762,6 +2765,11 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + chartjs-chart-sankey@0.14.0: + resolution: {integrity: sha512-MrU3lE73TE9kALy4MjWFlfcwf4R1EN/DBvhHxmv9n4AHap//JLKjlJTLIZwHsUjDsYo0B8PuMkrJODwfirEZUA==} + peerDependencies: + chart.js: '>=3.3.0' + chartjs-plugin-annotation@3.1.0: resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==} peerDependencies: @@ -8366,6 +8374,10 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-chart-sankey@0.14.0(chart.js@4.5.1): + dependencies: + chart.js: 4.5.1 + chartjs-plugin-annotation@3.1.0(chart.js@4.5.1): dependencies: chart.js: 4.5.1 diff --git a/proxy.conf.mjs b/proxy.conf.mjs index 71578d86..6f7a578d 100644 --- a/proxy.conf.mjs +++ b/proxy.conf.mjs @@ -13,6 +13,12 @@ export default { proxyReq.setHeader('origin', target) proxyReq.setHeader('referer', `${target}/`) }, + onProxyRes: (proxyRes) => { + // Fix http-proxy mangling 204 No Content responses + if (proxyRes.statusCode === 204) { + proxyRes.headers['content-length'] = '0' + } + }, }, '/media/': { target: process.env.SEED_HOST ?? 'http://127.0.0.1:8000', diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index 7aaa822f..810f4ce6 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -46,6 +46,7 @@ "AND": "AND", "API Documentation": "API Documentation", "API Key": "API Key", + "API Keys": "API Keys", "AT_ALL_DATA": "Audit Template submissions will be imported regardless of submission date. You can configure this setting in your SEED organization's settings page.", "AT_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.", "AT_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.", @@ -80,6 +81,7 @@ "Actual Column": "Actual Column", "Actual Field": "Actual Field", "Add": "Add", + "Add \/ Update": "Add \/ Update", "Add Access Level": "Add Access Level", "Add Access Level Instance": "Add Access Level Instance", "Add Column": "Add Column", @@ -116,9 +118,11 @@ "Address Line 2 (Tax Lot)": "Address Line 2 (Tax Lot)", "Admin": "Admin", "Advanced Settings": "Advanced Settings", + "Aggregate Meter": "Aggregate Meter", "Alias": "Alias", "All Canonical Fields": "All Canonical Fields", "All Extra Data Fields": "All Extra Data Fields", + "All Labels Benchmark Field": "All Labels Benchmark Field", "An Audit Template organization token, user email and password are required": "An Audit Template organization token, user email and password are required", "An error occurred while processing the file. Please ensure that your file meets the required specifications.": "An error occurred while processing the file. Please ensure that your file meets the required specifications.", "Analyses": "Analyses", @@ -148,6 +152,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", + "Audit Template": "Audit Template", "Audit Template Building ID": "Audit Template Building ID", "Audit Template Email": "Audit Template Email", "Audit Template Organization Token": "Audit Template Organization Token", @@ -169,11 +174,13 @@ "Back to List": "Back to List", "Back to Mapping": "Back to Mapping", "Baseline Cycle": "Baseline Cycle", + "Battery": "Battery", "Begin Update": "Begin Update", "Benchmark Configuration": "Benchmark Configuration", "Benchmarking": "Benchmarking", "Block Number": "Block Number", "Body": "Body", + "Boiler": "Boiler", "Boolean": "Boolean", "Both": "Both", "Building Certification": "Building Certification", @@ -236,6 +243,7 @@ "Changed By": "Changed By", "Chart Legend": "Chart Legend", "Chart Options": "Chart Options", + "Chiller": "Chiller", "Choose Existing Organization:": "Choose Existing Organization:", "Choose file": "Choose file", "Choose the year ending month for report period.": "Choose the year ending month for report period.", @@ -249,6 +257,9 @@ "Client Secret": "Client Secret", "Close": "Close", "Close Preview": "Close Preview", + "Coal (anthracite)": "Coal (anthracite)", + "Coal (bituminous)": "Coal (bituminous)", + "Coke": "Coke", "Collapse Tabs": "Collapse Tabs", "Column": "Column", "Column Description": "Column Description", @@ -287,6 +298,7 @@ "Configuration": "Configuration", "Configure Goals": "Configure Goals", "Configure Meter Details": "Configure Meter Details", + "Configure the fields used to identify and update benchmark records in Salesforce": "Configure the fields used to identify and update benchmark records in Salesforce", "Confirm": "Confirm", "Confirm Audit Template Building Import?": "Confirm Audit Template Building Import?", "Confirm Save Mappings?": "Confirm Save Mappings?", @@ -295,10 +307,13 @@ "Connected to Salesforce": "Connected to Salesforce", "Connection": "Connection", "Connection Status": "Connection Status", + "Connection Type": "Connection Type", "Connection error": "Connection error", "Contact": "Contact", "Contact Account Name Column": "Contact Account Name Column", "Contact Benchmark Field": "Contact Benchmark Field", + "Contact Email Column": "Contact Email Column", + "Contact Name Column": "Contact Name Column", "Contacts and Accounts": "Contacts and Accounts", "Content": "Content", "Continue to data mapping": "Continue to data mapping", @@ -306,7 +321,9 @@ "Cooling Capacity (Ton)": "Cooling Capacity (Ton)", "Copy Buildings": "Copy Buildings", "Copy Data File Headers directly to SEED Headers": "Copy Data File Headers directly to SEED Headers", + "Copy Inventory to New Cycle": "Copy Inventory to New Cycle", "Copy into Data File Headers": "Copy into Data File Headers", + "Cost": "Cost", "Could not delete the group": "Could not delete the group", "Count #": "Count #", "Create": "Create", @@ -358,12 +375,14 @@ "Custom ID 1": "Custom ID 1", "Custom ID 1 (Property)": "Custom ID 1 (Property)", "Custom ID 1 (Tax Lot)": "Custom ID 1 (Tax Lot)", + "Custom Meter": "Custom Meter", "Custom Name for Level": "Custom Name for Level", "Custom Reports": "Custom Reports", "Custom Reports require filter groups. Filter groups are created and configured on the": "Custom Reports require filter groups. Filter groups are created and configured on the", "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.": "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.", "Cycle": "Cycle", "Cycle Name": "Cycle Name", + "Cycle Name Benchmark Field": "Cycle Name Benchmark Field", "Cycle Selection": "Cycle Selection", "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.", @@ -385,10 +404,13 @@ "DEVELOPERS_FORUM": "The SEED Developers Forum contains various topics and joining enables you to connect with other developers. It is recommended to join this forum to submit developer questions, feature requests, and report issues as needed. Also, submitting issues on GitHub is encouraged.", "DID_YOU_REVIEW_YOUR_MAPPINGS": "Did you review your mappings? It's a good idea to double check your mappings. Once SEED matches your properties and tax lots you cannot undo or edit these mappings.", "DIRECTIONS_FOR_UPDATING_MQ_API_KEY": "If you'd like to geocode your data using the MapQuest service, please provide a valid API key within your organization's settings.", + "Dashboard": "Dashboard", "Data": "Data", "Data Administrator Account Name Column": "Data Administrator Account Name Column", "Data Administrator Contact Field": "Data Administrator Contact Field", "Data Administrator Contact Settings": "Data Administrator Contact Settings", + "Data Administrator Email Column": "Data Administrator Email Column", + "Data Administrator Name Column": "Data Administrator Name Column", "Data File Header": "Data File Header", "Data Files": "Data Files", "Data Load Error": "Data Load Error", @@ -422,6 +444,7 @@ "Delete All Mappings": "Delete All Mappings", "Delete Group": "Delete Group", "Delete Indication Label After Successful Salesforce Update": "Delete Indication Label After Successful Salesforce Update", + "Delete Meter": "Delete Meter", "Delete Organization": "Delete Organization", "Delete Selected": "Delete Selected", "Delete Sub-Organization": "Delete Sub-Organization", @@ -444,10 +467,20 @@ "Determine Time Period from Field:": "Determine Time Period from Field:", "Developer": "Developer", "Development Team": "Development Team", + "Diesel": "Diesel", "Dismiss": "Dismiss", + "Display Fields": "Display Fields", "Display Name": "Display Name", + "Display Units": "Display Units", "Distance to Target": "Distance to Target", "District": "District", + "District Chilled Water": "District Chilled Water", + "District Chilled Water - Absorption": "District Chilled Water - Absorption", + "District Chilled Water - Electric": "District Chilled Water - Electric", + "District Chilled Water - Engine": "District Chilled Water - Engine", + "District Chilled Water - Other": "District Chilled Water - Other", + "District Hot Water": "District Hot Water", + "District Steam": "District Steam", "Documentation": "Documentation", "Domain": "Domain", "Done": "Done", @@ -487,6 +520,7 @@ "Edit Access Levels": "Edit Access Levels", "Edit Fields for Selected": "Edit Fields for Selected", "Edit Group": "Edit Group", + "Edit Meter Connection": "Edit Meter Connection", "Edit Name": "Edit Name", "Edit Service for System": "Edit Service for System", "Edit System": "Edit System", @@ -494,6 +528,11 @@ "Edit\/Add Access Levels": "Edit\/Add Access Levels", "Efficiency (%)": "Efficiency (%)", "Elapsed:": "Elapsed:", + "Electric": "Electric", + "Electric - Grid": "Electric - Grid", + "Electric - Solar": "Electric - Solar", + "Electric - Unknown": "Electric - Unknown", + "Electric - Wind": "Electric - Wind", "Electric EUI Field": "Electric EUI Field", "Email": "Email", "Email Address": "Email Address", @@ -510,6 +549,7 @@ "Enable Salesforce Integration": "Enable Salesforce Integration", "Enable Salesforce Integration (Individual Properties)": "Enable Salesforce Integration (Individual Properties)", "Enable Salesforce Integration (Portfolio of Properties)": "Enable Salesforce Integration (Portfolio of Properties)", + "End Time": "End Time", "Energy": "Energy", "Energy Alerts": "Energy Alerts", "Energy Capacity (kWh)": "Energy Capacity (kWh)", @@ -519,12 +559,14 @@ "Enter Email Address": "Enter Email Address", "Enter a Name for the new Access Level Instance": "Enter a Name for the new Access Level Instance", "Enter a valid email address.": "Enter a valid email address.", + "Enter an identifying name": "Enter an identifying name", "Enter email address": "Enter email address", "Enter first name": "Enter first name", "Enter last name": "Enter last name", "Enter sub-organization name": "Enter sub-organization name", "Enter the minimum square footage for report period.": "Enter the minimum square footage for report period.", "Enter the minimum threshold count of buildings that can be returned in a shared query. The building count threshold is important for allowing other organizations to perform statistical analysis on your data without revealing information about individual buildings.": "Enter the minimum threshold count of buildings that can be returned in a shared query. The building count threshold is important for allowing other organizations to perform statistical analysis on your data without revealing information about individual buildings.", + "Enter your Salesforce OAuth credentials to connect portfolio data": "Enter your Salesforce OAuth credentials to connect portfolio data", "Error": "Error", "Error Processing Data": "Error Processing Data", "Errors occurred while importing access level instances:": "Errors occurred while importing access level instances:", @@ -553,6 +595,7 @@ "Export properties to your Audit Template account": "Export properties to your Audit Template account", "Export to Audit Template": "Export to Audit Template", "Export your Properties and Tax Lots": "Export your Properties and Tax Lots", + "Exported": "Exported", "Exporting properties to your Audit Template account": "Exporting properties to your Audit Template account", "Exporting selected properties...": "Exporting selected properties...", "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.", @@ -566,6 +609,8 @@ "FIELD_NAMES_FOR_MATCHING": "Field names for matching", "FILE_TYPES_SUPPORTED": "File types supported: .csv<\/strong>, .xls<\/strong>, .xlsx<\/strong>, .xml<\/strong>, .zip<\/strong>, .geojson<\/strong>, and .json<\/strong>.", "FOSSIL_FUEL_EUI_HELP": "Fossil Fuel EUI Threshold for the building (includes Gas, Etc.)", + "Failed to create meter": "Failed to create meter", + "Failed to create meters": "Failed to create meters", "Failed to delete inventory": "Failed to delete inventory", "Fair Actual to Benchmark EUI Ratio": "Fair Actual to Benchmark EUI Ratio", "Federal BPS Prescriptive Measures": "Federal BPS Prescriptive Measures", @@ -602,6 +647,7 @@ "Forgot password?": "Forgot password?", "Fossil Fuel EUI Threshold": "Fossil Fuel EUI Threshold", "Fossil Fuel-Fired Equipment RSL Threshold": "Fossil Fuel-Fired Equipment RSL Threshold", + "Fossil Fuels": "Fossil Fuels", "French (Canadian)": "French (Canadian)", "Frequently Asked Questions": "Frequently Asked Questions", "From Date": "From Date", @@ -610,6 +656,10 @@ "From first date of": "From first date of", "From the list below, select the fields that you want to: 1) share internally within your organization, and 2) share publicly with users outside your organization.": "From the list below, select the fields that you want to: 1) share internally within your organization, and 2) share publicly with users outside your organization.", "From the table below, select the rules that you want to: 1) enable\/disable within your organization, 2) modify the minimum\/maximum values to validate against on file upload, and 3) optionally assign or remove a label if the condition is not met.": "From the table below, select the rules that you want to: 1) enable\/disable within your organization, 2) modify the minimum\/maximum values to validate against on file upload, and 3) optionally assign or remove a label if the condition is not met.", + "Fuel Oil (No. 1)": "Fuel Oil (No. 1)", + "Fuel Oil (No. 2)": "Fuel Oil (No. 2)", + "Fuel Oil (No. 4)": "Fuel Oil (No. 4)", + "Fuel Oil (No. 5 and No. 6)": "Fuel Oil (No. 5 and No. 6)", "Funding from": "Funding from", "GEOCODED_MANUALLY": "geocoded manually", "GEOCODED_WITH_HIGH_CONFIDENCE": "successful with high confidence in the result", @@ -671,6 +721,7 @@ "Hello %(first_name)s, ": "Hello %(first_name)s, ", "Help": "Help", "Hexagonal Bins": "Hexagonal Bins", + "Hide": "Hide", "Hide All Labels": "Hide All Labels", "Highlights of SEED Platform™": "Highlights of SEED Platform™", "Historical Note": "Historical Note", @@ -705,6 +756,7 @@ "Import directly from ESPM": "Import directly from ESPM", "Import from Audit Template": "Import from Audit Template", "Import setting:": "Import setting:", + "Imported": "Imported", "In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.": "In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.", "Inactive": "Inactive", "Include Any": "Include Any", @@ -729,10 +781,12 @@ "Invite a new member": "Invite a new member", "Invite an Owner": "Invite an Owner", "Is Omitted": "Is Omitted", + "Is the meter using a service, or offering a service?": "Is the meter using a service, or offering a service?", "It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name from the original datafile.": "It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name from the original datafile.", "JACCARD_EXPLANATION": "The comparison relies on the Jaccard Index, which ranges from 0.0 (no overlap) to 1.0 (perfect match).", "Jurisdiction Property ID": "Jurisdiction Property ID", "Jurisdiction Tax Lot ID": "Jurisdiction Tax Lot ID", + "Kerosene": "Kerosene", "LINK_OUT_CYCLE_MATCHES": "Matches outside of this cycle will be linked.", "LISTING_ORG_MATCHING_CRITERIA": "For this organization, records match if the following fields match:", "LIST_GUIDANCE_PROPERTIES": "Select columns from the list below to make them appear in your Property List table. Drag the rows to change the order in which they appear. Pin the rows for them to be left-pinned in the list view.", @@ -849,9 +903,14 @@ "Merged tax lots against existing records": "Merged tax lots against existing records", "Merged tax lots between existing records": "Merged tax lots between existing records", "Merged tax lots within import file": "Merged tax lots within import file", + "Meter": "Meter", "Meter Data": "Meter Data", "Meter Usage": "Meter Usage", + "Meter created successfully": "Meter created successfully", + "Meter deleted successfully": "Meter deleted successfully", + "Meter updated successfully": "Meter updated successfully", "Meters": "Meters", + "Meters created successfully": "Meters created successfully", "Metric": "Metric", "Metric Actual Column": "Metric Actual Column", "Metric Name": "Metric Name", @@ -864,6 +923,7 @@ "Minimum Sq Ft": "Minimum Sq Ft", "Minute": "Minute", "Modifying Data Quality Rules": "Modifying Data Quality Rules", + "Month": "Month", "More details": "More details", "Move Buildings": "Move Buildings", "Move Inventory to a Different Access Level Instance": "Move Inventory to a Different Access Level Instance", @@ -887,6 +947,7 @@ "Name must be unique": "Name must be unique", "Name must not match any of its siblings": "Name must not match any of its siblings", "National Renewable Energy Laboratory": "National Renewable Energy Laboratory", + "Natural Gas": "Natural Gas", "New Analysis": "New Analysis", "New Build or Acquisition": "New Build or Acquisition", "New Goal": "New Goal", @@ -948,6 +1009,7 @@ "Organizations I Belong To": "Organizations I Belong To", "Organizations I Manage": "Organizations I Manage", "Organizations:": "Organizations:", + "Other": "Other", "Other fields can be manually updated on Audit Template following the export. Properties with Audit Template Building IDs will be skipped": "Other fields can be manually updated on Audit Template following the export. Properties with Audit Template Building IDs will be skipped", "Output Files": "Output Files", "Owner": "Owner", @@ -1016,6 +1078,9 @@ "Postal Code": "Postal Code", "Postal Code (Property)": "Postal Code (Property)", "Postal Code (Tax Lot)": "Postal Code (Tax Lot)", + "Potable Indoor": "Potable Indoor", + "Potable Outdoor": "Potable Outdoor", + "Potable: Mixed Indoor\/Outdoor": "Potable: Mixed Indoor\/Outdoor", "Potential Matches from Source": "Potential Matches from Source", "Power (kW)": "Power (kW)", "Power Capacity (kW)": "Power Capacity (kW)", @@ -1044,6 +1109,7 @@ "Project Information": "Project Information", "Project Name": "Project Name", "Projects": "Projects", + "Propane": "Propane", "Properties": "Properties", "Properties List": "Properties List", "Properties List (legacy)": "Properties List (legacy)", @@ -1080,12 +1146,14 @@ "REVIEW YOUR DATA MAPPINGS": "REVIEW YOUR DATA MAPPINGS", "REVIEW_MATCHING_CRITERIA": "Review your matching criteria on the Column Settings page before importing data for the first time. You will not be able to remove fields from the matching criteria after data is added to your organization.", "Read more here.": "Read more here.", + "Reading": "Mesure", "Really reset all passwords? This will sign you out of SEED.": "Really reset all passwords? This will sign you out of SEED.", "Recent Sale Date": "Recent Sale Date", "Recognize Empty": "Recognize Empty", "Record from Source": "Record from Source", "Refresh Metadata": "Refresh Metadata", "Refresh Metadata of Selected Properties and Tax Lots": "Refresh Metadata of Selected Properties and Tax Lots", + "Refresh Updated Timestamp": "Refresh Updated Timestamp", "Reject": "Reject", "Related Properties": "Related Properties", "Related Tax Lots": "Related Tax Lots", @@ -1137,8 +1205,10 @@ "Row 4": "Row 4", "Row 5": "Row 5", "Rules for a single field must have the same Data Type. Note that some Data Types are not available to certain Condition Checks.": "Rules for a single field must have the same Data Type. Note that some Data Types are not available to certain Condition Checks.", + "Run": "Run", "Run Analysis": "Run Analysis", "Run Author": "Run Author", + "Run Check": "Run Check", "Run Cycle": "Run Cycle", "Run Data Quality Check, available in the Actions dropdown, to auto-populate the \"Passed Checks\" column.": "Run Data Quality Check, available in the Actions dropdown, to auto-populate the \"Passed Checks\" column.", "Run Date": "Run Date", @@ -1151,6 +1221,7 @@ "SEED Home": "SEED Home", "SEED Platform": "SEED Platform", "SEED Platform™": "SEED Platform™", + "SEED Unique Benchmark ID Column": "SEED Unique Benchmark ID Column", "SEED Unique Benchmark ID Fieldname": "SEED Unique Benchmark ID Fieldname", "SEED doesn't currently support that file format": "SEED doesn't currently support that file format", "SEED is easy, flexible, and cost effective software designed to help organizations clean, manage and share information about large portfolios of buildings. SEED is a free, open source web application that you can use privately. While SEED was originally designed to help cities and States implement benchmarking programs for public or private buildings, it has the potential to be useful for many other activities by public entities, efficiency programs and private companies.": "SEED is easy, flexible, and cost effective software designed to help organizations clean, manage and share information about large portfolios of buildings. SEED is a free, open source web application that you can use privately. While SEED was originally designed to help cities and States implement benchmarking programs for public or private buildings, it has the potential to be useful for many other activities by public entities, efficiency programs and private companies.", @@ -1229,6 +1300,7 @@ "Salesforce Field Name": "Salesforce Field Name", "Salesforce Instance URL": "Salesforce URL", "Salesforce Integration": "Salesforce Integration", + "Salesforce URL": "Salesforce URL", "Salesforce Unique Benchmark ID Fieldname": "Salesforce Unique Benchmark ID Fieldname", "Salesforce connection successful": "Salesforce connection successful", "Save": "Save", @@ -1303,13 +1375,16 @@ "Snapshot projects are useful when you want to freeze the building data at a moment in time.": "Snapshot projects are useful when you want to freeze the building data at a moment in time.", "Sorry!": "Sorry!", "Sorting By (in order)": "Sorting By (in order)", + "Source": "Source", "Source EUI": "Source EUI", "Source EUI Weather Normalized": "Source EUI Weather Normalized", "Source Energy Use Intensity": "Source Energy Use Intensity", "Space Alerts": "Space Alerts", + "Start Time": "Start Time", "State": "State", "State (Property)": "State (Property)", "State (Tax Lot)": "State (Tax Lot)", + "Status Label Benchmark Field": "Status Label Benchmark Field", "Step 1: Add Access Levels": "Step 1: Add Access Levels", "Step 2: Upload Access Level Instances": "Step 2: Upload Access Level Instances", "Sub-Org Name": "Sub-Org Name", @@ -1327,6 +1402,7 @@ "Switch Profiles": "Switch Profiles", "System": "System", "System Type": "System Type", + "Systems & Services": "Systems & Services", "TAXLOT_MATCHING_FIELDS_REQUIREMENT": "At least one of the following Tax Lot fields is required", "THERMAL_CONV_ASSUMPTION_TITLE": "Thermal Conversion Assumption", "TOTAL_EUI_HELP": "Total EUI Threshold for the building (includes Electricity, Gas, etc.)", @@ -1369,6 +1445,8 @@ "Thank you for your interest in joining the Buildings Performance Database. Our site is in beta and your request is being reviewed for access.": "Thank you for your interest in joining the Buildings Performance Database. Our site is in beta and your request is being reviewed for access.", "Thank you!": "Thank you!", "The Building Energy Data Exchange Specification (BEDES, pronounced \"beads\" or \/bi:ds\/) is designed to support analysis of the measured energy performance of commercial, multifamily, and residential buildings, by providing a common data format, definitions, and an exchange protocol for building characteristics, efficiency measures, and energy use.": "The Building Energy Data Exchange Specification (BEDES, pronounced \"beads\" or \/bi:ds\/) is designed to support analysis of the measured energy performance of commercial, multifamily, and residential buildings, by providing a common data format, definitions, and an exchange protocol for building characteristics, efficiency measures, and energy use.", + "The Client ID from your Salesforce Connected App": "The Client ID from your Salesforce Connected App", + "The Client Secret from your Salesforce Connected App": "The Client Secret from your Salesforce Connected App", "The GeoJSON will expose all populated property and taxlot columns": "The GeoJSON will expose all populated property and taxlot columns", "The Open Source code is available on the Github organization SEED-Platform:": "The Open Source code is available on the Github organization SEED-Platform:", "The Public Feed functionality is currently": "The Public Feed functionality is currently", @@ -1378,6 +1456,7 @@ "The SEED team": "The SEED team", "The Standard Energy Efficiency Data (SEED)™ Platform is a software application that helps organizations easily manage data on the energy performance of large groups of buildings. Users can combine data from multiple sources, clean and validate it, and share the information with others. The software application provides an easy, flexible, and cost-effective method to improve the quality and availability of data to help demonstrate the economic and environmental benefits of energy efficiency, to implement programs, and to target investment activity.": "The Standard Energy Efficiency Data (SEED)™ Platform is a software application that helps organizations easily manage data on the energy performance of large groups of buildings. Users can combine data from multiple sources, clean and validate it, and share the information with others. The software application provides an easy, flexible, and cost-effective method to improve the quality and availability of data to help demonstrate the economic and environmental benefits of energy efficiency, to implement programs, and to target investment activity.", "The UBID Threshold defines the minimum Jaccard Index value required for a UBID Comparison to be considered acceptable. When the threshold is satisfied, the incoming property can be merged with the existing property.": "The UBID Threshold defines the minimum Jaccard Index value required for a UBID Comparison to be considered acceptable. When the threshold is satisfied, the incoming property can be merged with the existing property.", + "The URL of your Salesforce instance": "The URL of your Salesforce instance", "The comparison relies on the Jaccard Index, which ranges from 0.0 (no match) to 1.0 (perfect match).": "The comparison relies on the Jaccard Index, which ranges from 0.0 (no overlap) to 1.0 (perfect match).", "The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name.": "The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name.", "The following Property fields are required.": "The following Property fields are required.", @@ -1424,6 +1503,7 @@ "Two UBIDs are required for comparison. Click Custom UBIDs to continue.": "Two UBIDs are required for comparison. Click Custom UBIDs to continue.", "Two-Factor Authentication": "Two-Factor Authentication", "Type": "Type", + "Type - Source - Source ID": "Type - Source - Source ID", "Type of Submittal": "Type of Submittal", "Type of Submittal:": "Type of Submittal:", "U.S.\n Department of Energy": "U.S. Department of Energy", @@ -1459,6 +1539,7 @@ "USING_DEFAULT_UNITS_WARNING": "For columns with unit settings, default units will be used for conversions.", "Unexpected Portfolio Summary Calculations?": "Unexpected Portfolio Summary Calculations?", "Uniformat Category": "Uniformat Category", + "Unit": "Unit", "Units": "Units", "Unknown": "Unknown", "Unmerge Last": "Unmerge Last", @@ -1485,10 +1566,12 @@ "Upload Audit Template XML": "Upload Audit Template XML", "Upload BuildingSync Data": "Upload BuildingSync Data", "Upload Green Button Data": "Upload Green Button Data", + "Upload Meter Readings": "Upload Meter Readings", "Upload Portfolio Manager Data": "Upload Portfolio Manager Data", "Upload Single ESPM Property": "Upload ESPM Property", "Upload a Spreadsheet": "Upload a Spreadsheet", "Upload another energy data file": "Upload another energy data file", + "Upload failed": "Upload failed", "Upload your Buildings List": "Upload your Buildings List", "Upload your buildings list": "Upload your buildings list", "Upload your data": "Upload your data", @@ -1513,12 +1596,17 @@ "Version": "Version", "View Compliance Tracking": "View Compliance Tracking", "View Project": "View Project", + "View Readings": "View Readings", "View by Property": "View by Property", "View by Tax Lot": "View by Tax Lot", "View my properties": "View my properties", "View\/Hide Terms": "View\/Hide Terms", "Viewer": "Viewer", + "Views Count": "Views Count", + "Views Missing Gross Floor Area": "Views Missing Gross Floor Area", + "Views Missing Site EUI": "Views Missing Site EUI", "Violation Label": "Violation Label", + "Virtual": "Virtual", "Visit": "Visit", "Visit the BETTER website to run a portfolio analysis": "Visit the BETTER website to run a portfolio analysis", "Visualization Settings": "Visualization Settings", @@ -1544,6 +1632,7 @@ "What type of file would you like to upload?": "What type of file would you like to upload?", "When checked, properties\/taxlots will be matched to existing cycles based on the 'year_ending' column or the default cycle if there is no match": "When checked, properties\/taxlots will be matched to existing cycles based on the 'year_ending' column or the default cycle if there is no match", "Where would you like to move these buildings?": "Where would you like to move these buildings?", + "Wood": "Wood", "X Axis": "X Axis", "X Axis Column Selections:": "X Axis Column Selections:", "X Axis Column Types for Plotting:": "X Axis Column Types for Plotting:", diff --git a/public/i18n/es.json b/public/i18n/es.json index 66261442..76da38a3 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -46,6 +46,7 @@ "AND": "Y", "API Documentation": "Documentación API", "API Key": "Clave API", + "API Keys": "Claves API", "AT_ALL_DATA": "Los envíos de Plantillas de Auditoría se importarán independientemente de la fecha de envío. Puede configurar esta opción en la página de configuración de su organización de SEED.", "AT_AUTO_SYNC": "Si desea actualizar automáticamente su organización SEED con los datos de envío de la plantilla de auditoría para el ID de ciudad de la plantilla de auditoría seleccionada, configure los campos siguientes para programar su actualización semanal.", "AT_CITY_ID": "Especifique el ID de ciudad de su plantilla de auditoría. Este número aparece en la URL de la plantilla de auditoría cuando se accede a la pestaña \"CIUDADES\". SEED importará los datos de envío sólo para la ciudad especificada.", @@ -80,6 +81,7 @@ "Actual Column": "Columna real", "Actual Field": "Campo real", "Add": "Añadir", + "Add \/ Update": "Agregar\/Actualizar", "Add Access Level": "Añadir nivel de acceso", "Add Access Level Instance": "Añadir instancia de nivel de acceso", "Add Column": "Añadir columna", @@ -116,9 +118,11 @@ "Address Line 2 (Tax Lot)": "Dirección Línea 2 (Parcela fiscal)", "Admin": "Admin", "Advanced Settings": "Configuración avanzada", + "Aggregate Meter": "Medidor agregado", "Alias": "Alias", "All Canonical Fields": "Todos los campos canónicos", "All Extra Data Fields": "Todos los campos de datos adicionales", + "All Labels Benchmark Field": "Campo de referencia de todas las etiquetas", "An Audit Template organization token, user email and password are required": "Se requiere un token de organización de Plantilla de Auditoría, un correo electrónico de usuario y una contraseña", "An error occurred while processing the file. Please ensure that your file meets the required specifications.": "Se ha producido un error al procesar el archivo. Asegúrese de que su archivo cumple las especificaciones requeridas.", "Analyses": "Análisis", @@ -148,6 +152,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", + "Audit Template": "Plantilla de auditoría", "Audit Template Building ID": "Plantilla de auditoría ID del edificio", "Audit Template Email": "Plantilla de auditoría por correo electrónico", "Audit Template Organization Token": "Plantilla de auditoría Token de organización", @@ -169,11 +174,13 @@ "Back to List": "Volver a la lista", "Back to Mapping": "Volver a Cartografía", "Baseline Cycle": "Ciclo de referencia", + "Battery": "Batería", "Begin Update": "Iniciar actualización", "Benchmark Configuration": "Configuración de referencia", "Benchmarking": "Evaluación comparativa", "Block Number": "Número de bloque", "Body": "Cuerpo", + "Boiler": "Caldera", "Boolean": "Booleano", "Both": "Ambos", "Building Certification": "Certificación de edificios", @@ -236,6 +243,7 @@ "Changed By": "Cambiado por", "Chart Legend": "Leyenda del gráfico", "Chart Options": "Opciones de gráficos", + "Chiller": "Enfriador", "Choose Existing Organization:": "Seleccione Organización existente:", "Choose file": "Elegir archivo", "Choose the year ending month for report period.": "Seleccione el mes de final de año para el período del informe.", @@ -249,6 +257,9 @@ "Client Secret": "Secreto del cliente", "Close": "Cerrar", "Close Preview": "Cerrar Vista previa", + "Coal (anthracite)": "Carbón (antracita)", + "Coal (bituminous)": "Carbón (bituminoso)", + "Coke": "Coque", "Collapse Tabs": "Contraer pestañas", "Column": "Columna", "Column Description": "Descripción de la columna", @@ -287,6 +298,7 @@ "Configuration": "Configuración", "Configure Goals": "Configurar objetivos", "Configure Meter Details": "Configurar detalles del medidor", + "Configure the fields used to identify and update benchmark records in Salesforce": "Configure los campos utilizados para identificar y actualizar los registros de referencia en Salesforce.", "Confirm": "Confirme", "Confirm Audit Template Building Import?": "¿Confirmar la importación de plantillas de auditoría?", "Confirm Save Mappings?": "¿Confirmar guardar asignaciones?", @@ -295,10 +307,13 @@ "Connected to Salesforce": "Conectado a Salesforce", "Connection": "Conexión", "Connection Status": "Estado de la conexión", + "Connection Type": "Tipo de conexión", "Connection error": "Error de conexión", "Contact": "Póngase en contacto con", "Contact Account Name Column": "Columna Nombre de la cuenta de contacto", "Contact Benchmark Field": "Póngase en contacto con Benchmark Field", + "Contact Email Column": "Columna de correo electrónico de contacto", + "Contact Name Column": "Columna de nombre de contacto", "Contacts and Accounts": "Contactos y cuentas", "Content": "Contenido", "Continue to data mapping": "Continuar con el mapeo de datos", @@ -306,7 +321,9 @@ "Cooling Capacity (Ton)": "Capacidad de enfriamiento (toneladas)", "Copy Buildings": "Copiar edificios", "Copy Data File Headers directly to SEED Headers": "Copiar cabeceras de archivos de datos directamente en cabeceras de SEED", + "Copy Inventory to New Cycle": "Copiar inventario al nuevo ciclo", "Copy into Data File Headers": "Copiar en cabeceras de ficheros de datos", + "Cost": "Costo", "Could not delete the group": "No se pudo eliminar el grupo", "Count #": "Cuenta #", "Create": "Cree", @@ -358,12 +375,14 @@ "Custom ID 1": "ID personalizado 1", "Custom ID 1 (Property)": "ID personalizado 1 (Propiedad)", "Custom ID 1 (Tax Lot)": "Custom ID 1 (Lote fiscal)", + "Custom Meter": "Medidor personalizado", "Custom Name for Level": "Nombre personalizado para el nivel", "Custom Reports": "Informes personalizados", "Custom Reports require filter groups. Filter groups are created and configured on the": "Los informes personalizados requieren grupos de filtros. Los grupos de filtros se crean y configuran en el", "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.": "Se pueden enviar correos electrónicos personalizados a los Propietarios de Edificios utilizando las plantillas definidas a continuación. El correo electrónico se enviará a la dirección de correo electrónico del propietario del registro de SEED y actualmente no es configurable. La dirección de correo electrónico \"de\" es la misma que la dirección de correo electrónico del servidor que también se utiliza para enviar a los usuarios la información de su cuenta.", "Cycle": "Ciclo", "Cycle Name": "Nombre del ciclo", + "Cycle Name Benchmark Field": "Campo de referencia del nombre del ciclo", "Cycle Selection": "Selección de ciclos", "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.", @@ -385,10 +404,13 @@ "DEVELOPERS_FORUM": "El Foro de Desarrolladores de SEED contiene varios temas y unirse a él le permite conectar con otros desarrolladores. Se recomienda unirse a este foro para enviar preguntas de desarrolladores, solicitudes de características y reportar problemas según sea necesario. También se recomienda enviar problemas a GitHub.", "DID_YOU_REVIEW_YOUR_MAPPINGS": "¿Has revisado tus asignaciones? Es una buena idea volver a revisar sus asignaciones. Una vez que SEED hace coincidir sus propiedades y lotes fiscales, no puede deshacer o editar estas asignaciones.", "DIRECTIONS_FOR_UPDATING_MQ_API_KEY": "Si desea geocodificar sus datos utilizando el servicio MapQuest, proporcione una clave API válida en la configuración de su organización.", + "Dashboard": "Panel", "Data": "Datos", "Data Administrator Account Name Column": "Columna Nombre de cuenta del administrador de datos", "Data Administrator Contact Field": "Campo de contacto del administrador de datos", "Data Administrator Contact Settings": "Configuración del contacto del administrador de datos", + "Data Administrator Email Column": "Columna de correo electrónico del administrador de datos", + "Data Administrator Name Column": "Columna Nombre del administrador de datos", "Data File Header": "Cabecera del fichero de datos", "Data Files": "Ficheros de datos", "Data Load Error": "Error de carga de datos", @@ -422,6 +444,7 @@ "Delete All Mappings": "Borrar todas las asignaciones", "Delete Group": "Eliminar grupo", "Delete Indication Label After Successful Salesforce Update": "Eliminar etiqueta de indicación tras una actualización correcta de Salesforce", + "Delete Meter": "Eliminar medidor", "Delete Organization": "Borrar organización", "Delete Selected": "Borrar seleccionados", "Delete Sub-Organization": "Suprimir suborganización", @@ -444,10 +467,20 @@ "Determine Time Period from Field:": "Determinar el periodo de tiempo a partir del campo:", "Developer": "Desarrollador", "Development Team": "Equipo de desarrollo", + "Diesel": "Diesel", "Dismiss": "Desestimar", + "Display Fields": "Campos de visualización", "Display Name": "Mostrar nombre", + "Display Units": "Unidades de exhibición", "Distance to Target": "Distancia al objetivo", "District": "Distrito", + "District Chilled Water": "Agua refrigerada del distrito", + "District Chilled Water - Absorption": "Agua refrigerada del distrito - Absorción", + "District Chilled Water - Electric": "Sistema de agua refrigerada del distrito - Eléctrico", + "District Chilled Water - Engine": "Sistema de agua refrigerada del distrito - Motor", + "District Chilled Water - Other": "Agua refrigerada del distrito - Otros", + "District Hot Water": "Agua caliente del distrito", + "District Steam": "Vapor de distrito", "Documentation": "Documentación", "Domain": "Dominio", "Done": "Hecho", @@ -487,6 +520,7 @@ "Edit Access Levels": "Editar niveles de acceso", "Edit Fields for Selected": "Editar campos para seleccionados", "Edit Group": "Editar grupo", + "Edit Meter Connection": "Editar conexión del medidor", "Edit Name": "Editar nombre", "Edit Service for System": "Editar servicio para el sistema", "Edit System": "Sistema de edición", @@ -494,6 +528,11 @@ "Edit\/Add Access Levels": "Editar\/Agregar niveles de acceso", "Efficiency (%)": "Eficiencia (%)", "Elapsed:": "Transcurrido:", + "Electric": "Eléctrico", + "Electric - Grid": "Electricidad - Red", + "Electric - Solar": "Eléctrico - Solar", + "Electric - Unknown": "Eléctrico - Desconocido", + "Electric - Wind": "Eléctrico - Eólico", "Electric EUI Field": "Campo eléctrico EUI", "Email": "Correo electrónico", "Email Address": "Dirección de correo electrónico", @@ -510,6 +549,7 @@ "Enable Salesforce Integration": "Activar la integración de Salesforce", "Enable Salesforce Integration (Individual Properties)": "Habilitar la integración con Salesforce (propiedades individuales)", "Enable Salesforce Integration (Portfolio of Properties)": "Habilitar la integración con Salesforce (Cartera de propiedades)", + "End Time": "El Tiempo del Fin", "Energy": "Energía", "Energy Alerts": "Alertas de energía", "Energy Capacity (kWh)": "Capacidad energética (kWh)", @@ -519,12 +559,14 @@ "Enter Email Address": "Introduzca la dirección de correo electrónico", "Enter a Name for the new Access Level Instance": "Introduzca un Nombre para la nueva Instancia de Nivel de Acceso", "Enter a valid email address.": "Introduzca una dirección de correo electrónico válida.", + "Enter an identifying name": "Introduzca un nombre de identificación", "Enter email address": "Introduzca la dirección de correo electrónico", "Enter first name": "Introduzca el nombre", "Enter last name": "Introduzca el apellido", "Enter sub-organization name": "Introduzca el nombre de la suborganización", "Enter the minimum square footage for report period.": "Introduzca los metros cuadrados mínimos para el periodo del informe.", "Enter the minimum threshold count of buildings that can be returned in a shared query. The building count threshold is important for allowing other organizations to perform statistical analysis on your data without revealing information about individual buildings.": "Introduzca el umbral mínimo de recuento de edificios que puede devolverse en una consulta compartida. El umbral de recuento de edificios es importante para permitir que otras organizaciones realicen análisis estadísticos de sus datos sin revelar información sobre edificios individuales.", + "Enter your Salesforce OAuth credentials to connect portfolio data": "Ingrese sus credenciales de Salesforce OAuth para conectar los datos del portafolio.", "Error": "Error", "Error Processing Data": "Datos de procesamiento de errores", "Errors occurred while importing access level instances:": "Se produjeron errores al importar instancias de nivel de acceso:", @@ -553,6 +595,7 @@ "Export properties to your Audit Template account": "Exportar propiedades a su cuenta de Plantilla de Auditoría", "Export to Audit Template": "Exportar a plantilla de auditoría", "Export your Properties and Tax Lots": "Exporte sus propiedades y lotes fiscales", + "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...", "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.", @@ -566,6 +609,8 @@ "FIELD_NAMES_FOR_MATCHING": "Nombres de campo para la correspondencia", "FILE_TYPES_SUPPORTED": "Tipos de archivo admitidos: .csv<\/strong>, .xls<\/strong>, .xlsx<\/strong>, .xml<\/strong>, .zip<\/strong>, .geojson<\/strong>y .json<\/strong>.", "FOSSIL_FUEL_EUI_HELP": "Umbral EUI de combustibles fósiles para el edificio (incluye gas, etc.)", + "Failed to create meter": "No se pudo crear el medidor", + "Failed to create meters": "No se pudieron crear los medidores", "Failed to delete inventory": "Error al borrar inventario", "Fair Actual to Benchmark EUI Ratio": "Ratio EUI justo real\/de referencia", "Federal BPS Prescriptive Measures": "Medidas prescriptivas federales BPS", @@ -602,6 +647,7 @@ "Forgot password?": "¿Ha olvidado su contraseña?", "Fossil Fuel EUI Threshold": "Umbral EUI de combustibles fósiles", "Fossil Fuel-Fired Equipment RSL Threshold": "Equipos alimentados con combustibles fósiles Umbral RSL", + "Fossil Fuels": "Combustibles fósiles", "French (Canadian)": "Francés (canadiense)", "Frequently Asked Questions": "Preguntas Frecuentes", "From Date": "De Fecha", @@ -610,6 +656,10 @@ "From first date of": "Desde la primera fecha de", "From the list below, select the fields that you want to: 1) share internally within your organization, and 2) share publicly with users outside your organization.": "En la siguiente lista, seleccione los campos que desea: 1) compartir internamente dentro de su organización, y 2) compartir públicamente con usuarios fuera de su organización.", "From the table below, select the rules that you want to: 1) enable\/disable within your organization, 2) modify the minimum\/maximum values to validate against on file upload, and 3) optionally assign or remove a label if the condition is not met.": "En la tabla siguiente, seleccione las reglas que desea: 1) activar\/desactivar dentro de su organización, 2) modificar los valores mínimos\/máximos para validar contra en la carga de archivos, y 3) opcionalmente asignar o eliminar una etiqueta si no se cumple la condición.", + "Fuel Oil (No. 1)": "Gasóleo (n.º 1)", + "Fuel Oil (No. 2)": "Gasóleo (n.º 2)", + "Fuel Oil (No. 4)": "Gasóleo (n.º 4)", + "Fuel Oil (No. 5 and No. 6)": "Gasóleo (n.º 5 y n.º 6)", "Funding from": "Financiación de", "GEOCODED_MANUALLY": "geocodificado manualmente", "GEOCODED_WITH_HIGH_CONFIDENCE": "éxito con una gran confianza en el resultado", @@ -671,6 +721,7 @@ "Hello %(first_name)s, ": "Hola %(first_name)s, ", "Help": "Ayuda", "Hexagonal Bins": "Cubos hexagonales", + "Hide": "Esconder", "Hide All Labels": "Ocultar todas las etiquetas", "Highlights of SEED Platform™": "Aspectos destacados de la Plataforma SEED", "Historical Note": "Nota histórica", @@ -705,6 +756,7 @@ "Import directly from ESPM": "Importar directamente desde ESPM", "Import from Audit Template": "Importar desde plantilla de auditoría", "Import setting:": "Ajuste de importación:", + "Imported": "Importado", "In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.": "Además, debe especificar dónde debe asociarse el campo con los datos del Lote fiscal o con los datos de la Propiedad. Esto afectará a la forma en que los datos se comparan y combinan, así como a la forma en que se muestran en la vista Inventario.", "Inactive": "Inactivo", "Include Any": "Incluir cualquier", @@ -729,10 +781,12 @@ "Invite a new member": "Invitar a un nuevo miembro", "Invite an Owner": "Invitar a un propietario", "Is Omitted": "Se omite", + "Is the meter using a service, or offering a service?": "¿El contador utiliza un servicio o lo ofrece?", "It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name from the original datafile.": "Es necesario asignar sus nombres de campo a los nombres de campo de SEED. Puede seleccionar de la lista que aparece al empezar a escribir, que se basa en la Especificación de Intercambio de Datos Energéticos de Edificios (BEDES), o puede escribir su propio nombre, así como escribir el nombre de campo del archivo de datos original.", "JACCARD_EXPLANATION": "La comparación se basa en el índice de Jaccard, que oscila entre 0,0 (sin solapamiento) y 1,0 (coincidencia perfecta).", "Jurisdiction Property ID": "Jurisdicción ID de propiedad", "Jurisdiction Tax Lot ID": "Jurisdicción Número de identificación fiscal del lote", + "Kerosene": "Queroseno", "LINK_OUT_CYCLE_MATCHES": "Los partidos fuera de este ciclo serán enlazados.", "LISTING_ORG_MATCHING_CRITERIA": "Para esta organización, los registros coinciden si coinciden los siguientes campos:", "LIST_GUIDANCE_PROPERTIES": "Seleccione columnas de la siguiente lista para que aparezcan en su tabla Lista de propiedades. Arrastre las filas para cambiar el orden en que aparecen. Fije las filas para que aparezcan fijadas a la izquierda en la vista de lista.", @@ -849,9 +903,14 @@ "Merged tax lots against existing records": "Fusión de lotes fiscales con los registros existentes", "Merged tax lots between existing records": "Fusión de lotes fiscales entre registros existentes", "Merged tax lots within import file": "Fusión de lotes fiscales en el fichero de importación", + "Meter": "Metro", "Meter Data": "Datos del contador", "Meter Usage": "Uso del medidor", + "Meter created successfully": "Medidor creado correctamente", + "Meter deleted successfully": "Medidor eliminado correctamente", + "Meter updated successfully": "El medidor se actualizó correctamente.", "Meters": "Metros", + "Meters created successfully": "Medidores creados con éxito", "Metric": "Métrica", "Metric Actual Column": "Columna métrica real", "Metric Name": "Nombre de la métrica", @@ -864,6 +923,7 @@ "Minimum Sq Ft": "Superficie mínima", "Minute": "Minuto", "Modifying Data Quality Rules": "Modificación de las normas de calidad de datos", + "Month": "Mes", "More details": "Saber más", "Move Buildings": "Trasladar edificios", "Move Inventory to a Different Access Level Instance": "Mover Inventario a una Instancia de Nivel de Acceso Diferente", @@ -887,6 +947,7 @@ "Name must be unique": "El nombre debe ser único", "Name must not match any of its siblings": "El nombre no debe coincidir con ninguno de sus hermanos", "National Renewable Energy Laboratory": "Laboratorio Nacional de Energías Renovables", + "Natural Gas": "Gas natural", "New Analysis": "Nuevo análisis", "New Build or Acquisition": "Nueva construcción o adquisición", "New Goal": "Nuevo Objetivo", @@ -948,6 +1009,7 @@ "Organizations I Belong To": "Organizaciones a las que pertenezco", "Organizations I Manage": "Organizaciones que gestiono", "Organizations:": "Organizaciones:", + "Other": "Otro", "Other fields can be manually updated on Audit Template following the export. Properties with Audit Template Building IDs will be skipped": "Los demás campos pueden actualizarse manualmente en la plantilla de auditoría tras la exportación. Las propiedades con ID de edificio de plantilla de auditoría se omitirán", "Output Files": "Archivos de salida", "Owner": "Propietario", @@ -1016,6 +1078,9 @@ "Postal Code": "Código postal", "Postal Code (Property)": "Código postal (propiedad)", "Postal Code (Tax Lot)": "Código postal (lote fiscal)", + "Potable Indoor": "Potable para interiores", + "Potable Outdoor": "Potable para exteriores", + "Potable: Mixed Indoor\/Outdoor": "Apta para consumo: Uso mixto en interiores y exteriores.", "Potential Matches from Source": "Posibles coincidencias de origen", "Power (kW)": "Potencia (kW)", "Power Capacity (kW)": "Capacidad de potencia (kW)", @@ -1044,6 +1109,7 @@ "Project Information": "Información sobre el proyecto", "Project Name": "Nombre del proyecto", "Projects": "Proyectos", + "Propane": "Propano", "Properties": "Propiedades", "Properties List": "Lista de propiedades", "Properties List (legacy)": "Lista de propiedades (legado)", @@ -1080,12 +1146,14 @@ "REVIEW YOUR DATA MAPPINGS": "REVISE SUS ASIGNACIONES DE DATOS", "REVIEW_MATCHING_CRITERIA": "Revise sus criterios de correspondencia en la página Configuración de columna antes de importar datos por primera vez. No podrá eliminar campos de los criterios de correspondencia después de añadir los datos a su organización.", "Read more here.": "Más información aquí.", + "Reading": "Lectura", "Really reset all passwords? This will sign you out of SEED.": "¿Realmente restablecer todas las contraseñas? Esto te sacará de SEED.", "Recent Sale Date": "Fecha de venta reciente", "Recognize Empty": "Reconocer el vacío", "Record from Source": "Registro de origen", "Refresh Metadata": "Actualizar metadatos", "Refresh Metadata of Selected Properties and Tax Lots": "Actualizar metadatos de propiedades y lotes fiscales seleccionados", + "Refresh Updated Timestamp": "Actualizar marca de tiempo", "Reject": "Rechazar", "Related Properties": "Propiedades relacionadas", "Related Tax Lots": "Lotes fiscales relacionados", @@ -1137,8 +1205,10 @@ "Row 4": "Fila 4", "Row 5": "Fila 5", "Rules for a single field must have the same Data Type. Note that some Data Types are not available to certain Condition Checks.": "Las reglas para un mismo campo deben tener el mismo tipo de datos. Tenga en cuenta que algunos tipos de datos no están disponibles para determinadas comprobaciones de condiciones.", + "Run": "Correr", "Run Analysis": "Análisis de carrera", "Run Author": "Ejecutar Autor", + "Run Check": "Ejecutar comprobación", "Run Cycle": "Ciclo de ejecución", "Run Data Quality Check, available in the Actions dropdown, to auto-populate the \"Passed Checks\" column.": "Ejecute la verificación de calidad de datos, disponible en el menú desplegable Acciones, para completar automáticamente la columna \"Verificaciones aprobadas\".", "Run Date": "Fecha de ejecución", @@ -1151,6 +1221,7 @@ "SEED Home": "Página de inicio de SEED", "SEED Platform": "Plataforma SEED", "SEED Platform™": "Plataforma SEED", + "SEED Unique Benchmark ID Column": "Columna de ID de referencia única de SEMILLA", "SEED Unique Benchmark ID Fieldname": "SEED ID único de referencia Nombre del campo", "SEED doesn't currently support that file format": "SEED no admite actualmente ese formato de archivo", "SEED is easy, flexible, and cost effective software designed to help organizations clean, manage and share information about large portfolios of buildings. SEED is a free, open source web application that you can use privately. While SEED was originally designed to help cities and States implement benchmarking programs for public or private buildings, it has the potential to be useful for many other activities by public entities, efficiency programs and private companies.": "SEED es un software fácil, flexible y rentable diseñado para ayudar a las organizaciones a limpiar, gestionar y compartir información sobre grandes carteras de edificios. SEED es una aplicación web gratuita y de código abierto que se puede utilizar de forma privada. Aunque SEED se diseñó originalmente para ayudar a ciudades y Estados a implantar programas de evaluación comparativa de edificios públicos o privados, tiene el potencial de ser útil para muchas otras actividades de entidades públicas, programas de eficiencia y empresas privadas.", @@ -1229,6 +1300,7 @@ "Salesforce Field Name": "Nombre de campo de Salesforce", "Salesforce Instance URL": "URL de Salesforce", "Salesforce Integration": "Integración con Salesforce", + "Salesforce URL": "URL de Salesforce", "Salesforce Unique Benchmark ID Fieldname": "ID único de referencia de Salesforce Nombre de campo", "Salesforce connection successful": "Conexión con Salesforce exitosa", "Save": "Guardar", @@ -1303,13 +1375,16 @@ "Snapshot projects are useful when you want to freeze the building data at a moment in time.": "Los proyectos de instantáneas son útiles cuando se desea congelar los datos del edificio en un momento determinado.", "Sorry!": "¡Lo siento!", "Sorting By (in order)": "Clasificación por (en orden)", + "Source": "Fuente", "Source EUI": "Fuente EUI", "Source EUI Weather Normalized": "Fuente EUI Tiempo Normalizado", "Source Energy Use Intensity": "Fuente Intensidad de uso de la energía", "Space Alerts": "Alertas espaciales", + "Start Time": "Hora de inicio", "State": "Estado", "State (Property)": "Estado (Propiedad)", "State (Tax Lot)": "Estado (Lote fiscal)", + "Status Label Benchmark Field": "Campo de referencia de etiqueta de estado", "Step 1: Add Access Levels": "Paso 1: Añadir niveles de acceso", "Step 2: Upload Access Level Instances": "Paso 2: Cargar instancias de nivel de acceso", "Sub-Org Name": "Sub-Org Name", @@ -1327,6 +1402,7 @@ "Switch Profiles": "Perfiles de los interruptores", "System": "Sistema", "System Type": "Tipo de sistema", + "Systems & Services": "Sistemas y servicios", "TAXLOT_MATCHING_FIELDS_REQUIREMENT": "Se requiere al menos uno de los siguientes campos de Lote fiscal", "THERMAL_CONV_ASSUMPTION_TITLE": "Hipótesis de conversión térmica", "TOTAL_EUI_HELP": "Umbral EUI total del edificio (incluye electricidad, gas, etc.)", @@ -1369,6 +1445,8 @@ "Thank you for your interest in joining the Buildings Performance Database. Our site is in beta and your request is being reviewed for access.": "Gracias por su interés en formar parte de la base de datos de rendimiento de edificios. Nuestro sitio está en fase beta y su solicitud está siendo revisada para el acceso.", "Thank you!": "Gracias.", "The Building Energy Data Exchange Specification (BEDES, pronounced \"beads\" or \/bi:ds\/) is designed to support analysis of the measured energy performance of commercial, multifamily, and residential buildings, by providing a common data format, definitions, and an exchange protocol for building characteristics, efficiency measures, and energy use.": "La Especificación de Intercambio de Datos Energéticos de Edificios (BEDES, pronunciado \"beads\" o \/bi:ds\/) está diseñada para apoyar el análisis del rendimiento energético medido de edificios comerciales, multifamiliares y residenciales, proporcionando un formato de datos común, definiciones y un protocolo de intercambio para las características de los edificios, las medidas de eficiencia y el uso de la energía.", + "The Client ID from your Salesforce Connected App": "El ID de cliente de su aplicación conectada de Salesforce", + "The Client Secret from your Salesforce Connected App": "El secreto del cliente de tu aplicación conectada de Salesforce", "The GeoJSON will expose all populated property and taxlot columns": "El GeoJSON expondrá todas las columnas de propiedades y taxlots rellenadas", "The Open Source code is available on the Github organization SEED-Platform:": "El código fuente abierto está disponible en la organización Github SEED-Platform:", "The Public Feed functionality is currently": "La funcionalidad de Public Feed está actualmente", @@ -1378,6 +1456,7 @@ "The SEED team": "El equipo de SEED", "The Standard Energy Efficiency Data (SEED)™ Platform is a software application that helps organizations easily manage data on the energy performance of large groups of buildings. Users can combine data from multiple sources, clean and validate it, and share the information with others. The software application provides an easy, flexible, and cost-effective method to improve the quality and availability of data to help demonstrate the economic and environmental benefits of energy efficiency, to implement programs, and to target investment activity.": "La Plataforma de Datos Estándar de Eficiencia Energética (SEED)™ es una aplicación informática que ayuda a las organizaciones a gestionar fácilmente los datos sobre el rendimiento energético de grandes grupos de edificios. Los usuarios pueden combinar datos de múltiples fuentes, limpiarlos y validarlos, y compartir la información con otros. La aplicación de software proporciona un método fácil, flexible y rentable para mejorar la calidad y disponibilidad de los datos para ayudar a demostrar los beneficios económicos y ambientales de la eficiencia energética, para implementar programas y para orientar la actividad de inversión.", "The UBID Threshold defines the minimum Jaccard Index value required for a UBID Comparison to be considered acceptable. When the threshold is satisfied, the incoming property can be merged with the existing property.": "El Umbral UBID define el valor mínimo del Índice de Jaccard necesario para que una Comparación UBID se considere aceptable. Cuando se cumple el umbral, la propiedad entrante puede fusionarse con la propiedad existente.", + "The URL of your Salesforce instance": "La URL de tu instancia de Salesforce", "The comparison relies on the Jaccard Index, which ranges from 0.0 (no match) to 1.0 (perfect match).": "La comparación se basa en el índice de Jaccard, que oscila entre 0,0 (sin solapamiento) y 1,0 (coincidencia perfecta).", "The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name.": "El correo electrónico admite plantillas de corchetes para extraer datos del registro de propiedades de SEED. Por ejemplo, el siguiente fragmento reemplazará la latitud y longitud del registro de SEED. Se pueden añadir otros campos, pero asegúrese de utilizar el nombre del campo SEED y no el nombre para mostrar.", "The following Property fields are required.": "Los siguientes campos de Propiedad son obligatorios.", @@ -1424,6 +1503,7 @@ "Two UBIDs are required for comparison. Click Custom UBIDs to continue.": "Se necesitan dos UBIDs para la comparación. Haga clic en UBIDs personalizadas para continuar.", "Two-Factor Authentication": "Autenticación de dos factores", "Type": "Tipo", + "Type - Source - Source ID": "Tipo - Origen - ID de origen", "Type of Submittal": "Tipo de presentación", "Type of Submittal:": "Tipo de presentación:", "U.S.\n Department of Energy": "Departamento de Energía de EE.UU.", @@ -1459,6 +1539,7 @@ "USING_DEFAULT_UNITS_WARNING": "Para las columnas con configuración de unidades, se utilizarán las unidades por defecto para las conversiones.", "Unexpected Portfolio Summary Calculations?": "¿Cálculos de resumen de cartera inesperados?", "Uniformat Category": "Categoría Uniformat", + "Unit": "Unidad", "Units": "Unidades", "Unknown": "Desconocido", "Unmerge Last": "Fusión final", @@ -1485,10 +1566,12 @@ "Upload Audit Template XML": "Cargar plantilla de auditoría XML", "Upload BuildingSync Data": "Cargar datos de BuildingSync", "Upload Green Button Data": "Cargar datos del Botón Verde", + "Upload Meter Readings": "Subir lecturas del contador", "Upload Portfolio Manager Data": "Cargar datos de Portfolio Manager", "Upload Single ESPM Property": "Cargar propiedad ESPM", "Upload a Spreadsheet": "Cargar una hoja de cálculo", "Upload another energy data file": "Cargar otro archivo de datos de energía", + "Upload failed": "Error al cargar la página", "Upload your Buildings List": "Cargue su lista de edificios", "Upload your buildings list": "Cargue su lista de edificios", "Upload your data": "Cargue sus datos", @@ -1513,12 +1596,17 @@ "Version": "Versión", "View Compliance Tracking": "Ver el seguimiento del cumplimiento", "View Project": "Ver proyecto", + "View Readings": "Ver lecturas", "View by Property": "Ver por propiedad", "View by Tax Lot": "Ver por lote fiscal", "View my properties": "Ver mis propiedades", "View\/Hide Terms": "Ver\/Ocultar términos", "Viewer": "Visor", + "Views Count": "Número de visualizaciones", + "Views Missing Gross Floor Area": "Vistas Faltantes Superficie Bruta del Piso", + "Views Missing Site EUI": "Vistas Sitio faltante EUI", "Violation Label": "Etiqueta de infracción", + "Virtual": "Virtual", "Visit": "Visite", "Visit the BETTER website to run a portfolio analysis": "Visite el sitio web de BETTER para realizar un análisis de cartera", "Visualization Settings": "Ajustes de visualización", @@ -1544,6 +1632,7 @@ "What type of file would you like to upload?": "¿Qué tipo de archivo desea cargar?", "When checked, properties\/taxlots will be matched to existing cycles based on the 'year_ending' column or the default cycle if there is no match": "Si se selecciona esta opción, las propiedades\/parcelas fiscales se ajustarán a los ciclos existentes en función de la columna \"year_ending\" o al ciclo predeterminado si no hay ninguna coincidencia", "Where would you like to move these buildings?": "¿Dónde le gustaría trasladar estos edificios?", + "Wood": "Madera", "X Axis": "Eje X", "X Axis Column Selections:": "Selecciones de columna del eje X:", "X Axis Column Types for Plotting:": "Tipos de columnas del eje X para plotear:", diff --git a/public/i18n/fr_CA.json b/public/i18n/fr_CA.json index fba0bb45..4485debc 100644 --- a/public/i18n/fr_CA.json +++ b/public/i18n/fr_CA.json @@ -46,6 +46,7 @@ "AND": "ET", "API Documentation": "Documentation de l'API", "API Key": "Clé API", + "API Keys": "Clés API", "AT_ALL_DATA": "Les soumissions de modèles d’audit seront importées quelle que soit la date de soumission. Vous pouvez configurer ce paramètre dans la page des paramètres de votre organisation SEED.", "AT_AUTO_SYNC": "Si vous souhaitez mettre à jour automatiquement votre organisation SEED avec les données de soumission du modèle d'audit pour l'ID de ville du modèle d'audit sélectionné, configurez les champs ci-dessous pour planifier votre mise à jour hebdomadaire.", "AT_CITY_ID": "Spécifiez l'ID de votre ville dans Audit Template. Ce numéro est visible dans l'URL du site Audit Template lorsque vous accédez à l'onglet « CITIES ». SEED importera les données de soumission pour la ville spécifiée uniquement.", @@ -80,6 +81,7 @@ "Actual Column": "Colonne réelle", "Actual Field": "Champ réel", "Add": "Ajouter", + "Add \/ Update": "Ajouter \/ Mettre à jour", "Add Access Level": "Ajouter un niveau d'accès", "Add Access Level Instance": "Ajouter une instance de niveau d'accès", "Add Column": "Ajouter une colonne", @@ -116,9 +118,11 @@ "Address Line 2 (Tax Lot)": "Adresse Ligne 2 (Lot d'impôt)", "Admin": "Admin", "Advanced Settings": "Réglages avancés", + "Aggregate Meter": "Compteur d'agrégats", "Alias": "Alias", "All Canonical Fields": "Tous les champs canoniques", "All Extra Data Fields": "Tous les champs de données supplémentaires", + "All Labels Benchmark Field": "Champ de référence pour toutes les étiquettes", "An Audit Template organization token, user email and password are required": "Un jeton d'organisation Audit Template, un e-mail d'utilisateur et un mot de passe sont requis", "An error occurred while processing the file. Please ensure that your file meets the required specifications.": "Une erreur s'est produite lors du traitement du fichier. Assurez-vous que votre fichier respecte les spécifications requises.", "Analyses": "Analyses", @@ -148,6 +152,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é", + "Audit Template": "Modèle d'audit", "Audit Template Building ID": "Audit Template ID de bâtiment", "Audit Template Email": "Audit Template Email", "Audit Template Organization Token": "Audit Template jeton d'organisation", @@ -169,11 +174,13 @@ "Back to List": "Retour à la Liste", "Back to Mapping": "Retournez à les mappages", "Baseline Cycle": "Cycle de référence", + "Battery": "Batterie", "Begin Update": "Démarrer la mise à jour", "Benchmark Configuration": "Configuration de référence", "Benchmarking": "Analyse comparative", "Block Number": "Numéro de bloc", "Body": "Corps", + "Boiler": "Chaudière", "Boolean": "Booléen", "Both": "Les deux", "Building Certification": "Certification de bâtiment", @@ -236,6 +243,7 @@ "Changed By": "Changé par", "Chart Legend": "Légende du graphique", "Chart Options": "Options du graphique", + "Chiller": "Refroidisseur", "Choose Existing Organization:": "Choisissez Organisation Existante:", "Choose file": "Choisir le fichier", "Choose the year ending month for report period.": "Choisissez le mois de fin de l'année pour la période du rapport.", @@ -249,6 +257,9 @@ "Client Secret": "Secret du client", "Close": "Fermer", "Close Preview": "Fermer l'aperçu", + "Coal (anthracite)": "Charbon (anthracite)", + "Coal (bituminous)": "Charbon (bitumineux)", + "Coke": "Coke", "Collapse Tabs": "Réduire les onglets", "Column": "Colonne", "Column Description": "Description de la colonne", @@ -287,6 +298,7 @@ "Configuration": "Configuration", "Configure Goals": "Configurer les Objectifs", "Configure Meter Details": "Configurer les détails du compteur", + "Configure the fields used to identify and update benchmark records in Salesforce": "Configurez les champs utilisés pour identifier et mettre à jour les enregistrements de référence dans Salesforce.", "Confirm": "Confirmer", "Confirm Audit Template Building Import?": "Confirmer l'importation de la création du modèle d'audit ?", "Confirm Save Mappings?": "Confirmer enregistrer les mappages?", @@ -295,10 +307,13 @@ "Connected to Salesforce": "Connecté à Salesforce", "Connection": "Connexion", "Connection Status": "État de la connexion", + "Connection Type": "Type de connexion", "Connection error": "Erreur de connexion", "Contact": "Contact", "Contact Account Name Column": "Colonne Nom du compte de contact", "Contact Benchmark Field": "Nom de champ Contact pour l'object Benchmark", + "Contact Email Column": "Colonne de courriel de contact", + "Contact Name Column": "Colonne Nom du contact", "Contacts and Accounts": "Contacts et Comptes", "Content": "Contenu", "Continue to data mapping": "Continuer à la mappage de données", @@ -306,7 +321,9 @@ "Cooling Capacity (Ton)": "Capacité de refroidissement (tonnes)", "Copy Buildings": "Copier des bâtiments", "Copy Data File Headers directly to SEED Headers": "Copiez les en-têtes de fichiers de données directement dans les en-têtes SEED", + "Copy Inventory to New Cycle": "Copier l'inventaire vers le nouveau cycle", "Copy into Data File Headers": "Copier dans les en-têtes de fichiers de données", + "Cost": "Coût", "Could not delete the group": "Impossible de supprimer le groupe", "Count #": "Compte #", "Create": "Créer", @@ -358,12 +375,14 @@ "Custom ID 1": "ID personnalisée 1", "Custom ID 1 (Property)": "ID personnalisé 1 (propriété)", "Custom ID 1 (Tax Lot)": "ID personnalisé 1 (lot d'impôt)", + "Custom Meter": "Compteur personnalisé", "Custom Name for Level": "Nom personnalisé pour le niveau", "Custom Reports": "Rapports", "Custom Reports require filter groups. Filter groups are created and configured on the": "Les rapports personnalisés nécessitent des groupes de filtres. Les groupes de filtres sont créés et configurés sur le", "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.": "Des e-mails personnalisés peuvent être envoyés aux propriétaires d'immeubles à l'aide des modèles définis ci-dessous. L'e-mail sera envoyé à l'adresse e-mail du propriétaire de l'enregistrement SEED et n'est actuellement pas configurable. L'adresse e-mail \"de\" est la même que l'adresse e-mail du serveur qui est également utilisée pour envoyer aux utilisateurs leurs informations de compte par e-mail.", "Cycle": "Cycle", "Cycle Name": "Nom du cycle", + "Cycle Name Benchmark Field": "Nom du cycle Champ de référence", "Cycle Selection": "Sélection des cycles", "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.", @@ -385,10 +404,13 @@ "DEVELOPERS_FORUM": "Le forum des développeurs SEED contient divers sujets et vous permet de vous connecter avec d'autres développeurs. Il est recommandé de rejoindre ce forum pour soumettre des questions de développeurs, des demandes de fonctionnalités et signaler des problèmes le cas échéant. Il est également recommandé de soumettre des problèmes sur GitHub.", "DID_YOU_REVIEW_YOUR_MAPPINGS": "Avez-vous revu vos correspondances? C'est une bonne idée de vérifier vos correspondances. Une fois que SEED correspond à vos propriétés et lots d'impôt, vous ne pouvez pas annuler ou modifier ces mappages.", "DIRECTIONS_FOR_UPDATING_MQ_API_KEY": "Si vous souhaitez géocoder vos données à l'aide du service MapQuest, veuillez fournir une clé API valide dans les paramètres de votre organisation.", + "Dashboard": "Tableau de bord", "Data": "Les données", "Data Administrator Account Name Column": "Colonne Nom du compte de l'administrateur de données", "Data Administrator Contact Field": "Champ de contact de l'administrateur des données", "Data Administrator Contact Settings": "Paramètres de contact de l'administrateur de données", + "Data Administrator Email Column": "Colonne de courriel de l'administrateur de données", + "Data Administrator Name Column": "Colonne Nom de l'administrateur de données", "Data File Header": "En-tête du fichier de données", "Data Files": "Fichiers de données", "Data Load Error": "Erreur de chargement de données", @@ -422,6 +444,7 @@ "Delete All Mappings": "Supprimer tous les mappages", "Delete Group": "Supprimer le groupe", "Delete Indication Label After Successful Salesforce Update": "Supprimer l'étiquette d'indication après une mise à jour réussie de Salesforce", + "Delete Meter": "Supprimer le compteur", "Delete Organization": "Supprimer l'organisation", "Delete Selected": "Supprimer la sélection", "Delete Sub-Organization": "Supprimer la sous-organisation", @@ -444,10 +467,20 @@ "Determine Time Period from Field:": "Déterminer la durée du champ:", "Developer": "Développeur", "Development Team": "Équipe de développement", + "Diesel": "Diesel", "Dismiss": "Rejeter", + "Display Fields": "Afficher les champs", "Display Name": "Afficher un nom", + "Display Units": "Unités d'affichage", "Distance to Target": "Distance à la cible", "District": "District", + "District Chilled Water": "Réseau d'eau glacée", + "District Chilled Water - Absorption": "Réseau d'eau glacée - Absorption", + "District Chilled Water - Electric": "Réseau d'eau glacée - Électrique", + "District Chilled Water - Engine": "Réseau d'eau glacée - Moteur", + "District Chilled Water - Other": "Eau glacée du réseau - Autres", + "District Hot Water": "Eau chaude sanitaire du réseau", + "District Steam": "Vapeur de district", "Documentation": "Documentation", "Domain": "Domaine", "Done": "Fait", @@ -487,6 +520,7 @@ "Edit Access Levels": "Modifier les niveaux d'accès", "Edit Fields for Selected": "Modifier les champs pour les sélections", "Edit Group": "Modifier le groupe", + "Edit Meter Connection": "Modifier la connexion du compteur", "Edit Name": "Modifier le nom", "Edit Service for System": "Modifier le service pour le système", "Edit System": "Système d'édition", @@ -494,6 +528,11 @@ "Edit\/Add Access Levels": "Modifier\/Ajouter des niveaux d'accès", "Efficiency (%)": "Efficacité (%)", "Elapsed:": "Écoulé:", + "Electric": "Électrique", + "Electric - Grid": "Réseau électrique", + "Electric - Solar": "Électricité - Solaire", + "Electric - Unknown": "Électrique - Inconnu", + "Electric - Wind": "Électrique - Éolienne", "Electric EUI Field": "Champ de l'UEI électrique", "Email": "Email", "Email Address": "adresse e-mail", @@ -510,6 +549,7 @@ "Enable Salesforce Integration": "Activer la fonctionnalité Salesforce", "Enable Salesforce Integration (Individual Properties)": "Activer l'intégration Salesforce (propriétés individuelles)", "Enable Salesforce Integration (Portfolio of Properties)": "Activer l'intégration Salesforce (Portefeuille de propriétés)", + "End Time": "L'heure de Fin", "Energy": "Énergie", "Energy Alerts": "Alertes énergétiques", "Energy Capacity (kWh)": "Capacité énergétique (kWh)", @@ -519,12 +559,14 @@ "Enter Email Address": "Entrer l'adresse e-mail", "Enter a Name for the new Access Level Instance": "Entrez un nom pour la nouvelle instance de niveau d'accès", "Enter a valid email address.": "Entrez une adresse mail valide.", + "Enter an identifying name": "Saisissez un nom d'identification", "Enter email address": "Entrer l'adresse e-mail", "Enter first name": "Entrez le prénom", "Enter last name": "Entrez le nom de famille", "Enter sub-organization name": "Entrez le nom de la sous-organisation", "Enter the minimum square footage for report period.": "Entrez la superficie minimale pour la période du rapport.", "Enter the minimum threshold count of buildings that can be returned in a shared query. The building count threshold is important for allowing other organizations to perform statistical analysis on your data without revealing information about individual buildings.": "Entrez le nombre de seuils minimal des bâtiments pouvant être renvoyés dans une requête partagée. Le seuil de nombre de bâtiments est important pour permettre à d'autres organisations d'effectuer une analyse statistique de vos données sans révéler d'informations sur les bâtiments individuels.", + "Enter your Salesforce OAuth credentials to connect portfolio data": "Saisissez vos identifiants OAuth Salesforce pour connecter les données du portefeuille", "Error": "Erreur", "Error Processing Data": "Erreur lors du traitement des données", "Errors occurred while importing access level instances:": "Des erreurs se sont produites lors de l'importation des instances de niveau d'accès :", @@ -553,6 +595,7 @@ "Export properties to your Audit Template account": "Exporter les propriétés vers votre compte de modèle d'audit", "Export to Audit Template": "Exporter vers un modèle d'audit", "Export your Properties and Tax Lots": "Exportez vos propriétés et vos lots d'impôt", + "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...", "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.", @@ -566,6 +609,8 @@ "FIELD_NAMES_FOR_MATCHING": "Noms de zone pour l'appariement", "FILE_TYPES_SUPPORTED": "Types de fichiers pris en charge: .csv<\/strong>, .xls<\/strong>, .xlsx<\/strong>, .xml<\/strong>, .zip<\/strong>, .geojson<\/strong>, et .json<\/strong>.", "FOSSIL_FUEL_EUI_HELP": "Le seuil d'IEC en matière de combustibles fossiles pour le bâtiment (y compris le gaz, etc.)", + "Failed to create meter": "Échec de la création du compteur", + "Failed to create meters": "Impossible de créer des compteurs", "Failed to delete inventory": "Échec de la suppression de l'inventaire", "Fair Actual to Benchmark EUI Ratio": "Ratio de l'UEI réel\/référence équitable", "Federal BPS Prescriptive Measures": "Mesures prescriptives du SPB fédéral", @@ -602,6 +647,7 @@ "Forgot password?": "mot de passe oublié?", "Fossil Fuel EUI Threshold": "Le seuil de l'UEI sur les combustibles fossiles", "Fossil Fuel-Fired Equipment RSL Threshold": "Seuil RSL pour les équipements alimentés aux combustibles fossiles", + "Fossil Fuels": "Combustibles fossiles", "French (Canadian)": "Français (Canada)", "Frequently Asked Questions": "Questions Fréquemment Posées", "From Date": "À Partir De La Date", @@ -610,6 +656,10 @@ "From first date of": "De la première date de", "From the list below, select the fields that you want to: 1) share internally within your organization, and 2) share publicly with users outside your organization.": "Dans la liste ci-dessous, sélectionnez les champs que vous souhaitez: 1) partager en interne dans votre organisation et 2) partager publiquement avec des utilisateurs extérieurs à votre organisation.", "From the table below, select the rules that you want to: 1) enable\/disable within your organization, 2) modify the minimum\/maximum values to validate against on file upload, and 3) optionally assign or remove a label if the condition is not met.": "Dans le tableau ci-dessous, sélectionnez les règles que vous souhaitez: 1) activer \/ désactiver au sein de votre organisation, 2) modifier les valeurs minimum \/ maximum pour valider le téléchargement sur le fichier, et 3) affecter ou supprimer facultativement une étiquette si la condition est pas rencontré.", + "Fuel Oil (No. 1)": "Fioul domestique (n° 1)", + "Fuel Oil (No. 2)": "Fioul domestique (n° 2)", + "Fuel Oil (No. 4)": "Fioul (n° 4)", + "Fuel Oil (No. 5 and No. 6)": "Fioul (n° 5 et n° 6)", "Funding from": "Financement à partir de", "GEOCODED_MANUALLY": "géocodé manuellement", "GEOCODED_WITH_HIGH_CONFIDENCE": "succès avec une grande confiance dans le résultat", @@ -671,6 +721,7 @@ "Hello %(first_name)s, ": "Bonjour% (first_name) s,", "Help": "Aide", "Hexagonal Bins": "Bacs Hexagonaux", + "Hide": "Cacher", "Hide All Labels": "Masquer toutes les étiquettes", "Highlights of SEED Platform™": "Points forts de la Plate-Forme SEED™", "Historical Note": "Note historique", @@ -705,6 +756,7 @@ "Import directly from ESPM": "Importer directement depuis ESPM", "Import from Audit Template": "Importer à partir du Audit Template", "Import setting:": "Paramètre d'importation :", + "Imported": "Importé", "In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.": "En outre, vous devez spécifier où le champ doit être associé aux données du lot d'impôt ou aux données de la propriété. Cela affectera la manière dont les données sont mises en correspondance et fusionnées, ainsi que la manière dont elles sont affichées dans la vue Inventaire.", "Inactive": "Inactif", "Include Any": "Inclure tout", @@ -729,10 +781,12 @@ "Invite a new member": "Inviter un nouveau membre", "Invite an Owner": "Inviter un propriétaire", "Is Omitted": "Est omis", + "Is the meter using a service, or offering a service?": "Le compteur utilise-t-il un service ou offre-t-il un service ?", "It is necessary to map your field names to SEED field names. You can select from the list that appears as you start to type, which is based on the Building Energy Data Exchange Specification (BEDES), or you can type in your own name, as well as typing in the field name from the original datafile.": "Il est nécessaire de mapper vos noms de champs aux noms de champs SEED. Vous pouvez sélectionner dans la liste qui apparaît lorsque vous commencez à taper, qui est basée sur la spécification BEDES (Building Energy Data Exchange Specification) ou vous pouvez taper votre propre nom et saisir le nom du champ dans le fichier de données d'origine.", "JACCARD_EXPLANATION": "La comparaison repose sur l'indice de Jaccard, qui varie de 0.0 (aucune superposition) à 1.0 (correspondance parfaite).", "Jurisdiction Property ID": "Juridiction", "Jurisdiction Tax Lot ID": "ID de lot d'impôt de juridiction", + "Kerosene": "Kérosène", "LINK_OUT_CYCLE_MATCHES": "Les matchs en dehors de ce cycle seront liés.", "LISTING_ORG_MATCHING_CRITERIA": "Pour cette organisation, les enregistrements correspondent si les champs suivants correspondent:", "LIST_GUIDANCE_PROPERTIES": "Sélectionnez des colonnes dans la liste ci-dessous pour les faire apparaître dans votre liste de propriétés. Faites glisser les lignes pour modifier l'ordre dans lequel elles apparaissent. Épinglez les lignes pour qu'elles soient épinglées dans la vue de liste.", @@ -849,9 +903,14 @@ "Merged tax lots against existing records": "Fusion de lots de taxes avec des enregistrements existants", "Merged tax lots between existing records": "Fusion de lots de taxes entre des enregistrements existants", "Merged tax lots within import file": "Fusion des lots de taxe dans le fichier d'importation", + "Meter": "Mètre", "Meter Data": "Données du compteur", "Meter Usage": "Utilisation du compteur", + "Meter created successfully": "Compteur créé avec succès", + "Meter deleted successfully": "Compteur supprimé avec succès", + "Meter updated successfully": "Mise à jour du compteur réussie", "Meters": "Mètres", + "Meters created successfully": "Compteurs créés avec succès", "Metric": "Métrique", "Metric Actual Column": "Colonne de mesures réelles", "Metric Name": "Nom de la métrique", @@ -864,6 +923,7 @@ "Minimum Sq Ft": "Pi\/Ca Minimum", "Minute": "Minute", "Modifying Data Quality Rules": "Modification des règles de qualité des données", + "Month": "Mois", "More details": "Plus de détails", "Move Buildings": "Déplacer les bâtiments", "Move Inventory to a Different Access Level Instance": "Déplacer l'inventaire vers une instance de niveau d'accès différent", @@ -887,6 +947,7 @@ "Name must be unique": "Le nom doit être unique", "Name must not match any of its siblings": "Le nom ne doit pas correspondre à aucun de ses frères et sœurs", "National Renewable Energy Laboratory": "Laboratoire National Des Énergies Renouvelables", + "Natural Gas": "Gaz naturel", "New Analysis": "Nouvelle analyse", "New Build or Acquisition": "Nouvelle construction ou acquisition", "New Goal": "Nouvel Objectif", @@ -948,6 +1009,7 @@ "Organizations I Belong To": "Organisations auxquelles j'appartiens", "Organizations I Manage": "Organisations que je gère", "Organizations:": "Organisations:", + "Other": "Autre", "Other fields can be manually updated on Audit Template following the export. Properties with Audit Template Building IDs will be skipped": "D'autres champs peuvent être mis à jour manuellement sur le modèle d'audit après l'exportation. Les propriétés avec des ID de bâtiment de modèle d'audit seront ignorées", "Output Files": "Fichiers de sortie", "Owner": "Propriétaire", @@ -1016,6 +1078,9 @@ "Postal Code": "Code postal", "Postal Code (Property)": "Code postal (propriété)", "Postal Code (Tax Lot)": "Code postal (lot d'impôt)", + "Potable Indoor": "Eau potable d'intérieur", + "Potable Outdoor": "Eau potable d'extérieur", + "Potable: Mixed Indoor\/Outdoor": "Potable : Mixte intérieur\/extérieur", "Potential Matches from Source": "Correspondances possibles à partir de la source", "Power (kW)": "Puissance (kW)", "Power Capacity (kW)": "Capacité de puissance (kW)", @@ -1044,6 +1109,7 @@ "Project Information": "Renseignements sur le projet", "Project Name": "Nom du projet", "Projects": "Projets", + "Propane": "Propane", "Properties": "Propriétés", "Properties List": "Liste Des Propriétés", "Properties List (legacy)": "Liste des propriétés (héritage)", @@ -1080,12 +1146,14 @@ "REVIEW YOUR DATA MAPPINGS": "EXAMINER VOS MAPPAGES DE DONNÉES", "REVIEW_MATCHING_CRITERIA": "Vérifiez vos critères de correspondance sur la page Paramètres de colonne avant d'importer des données pour la première fois. Vous ne pourrez pas supprimer des champs des critères de correspondance une fois les données ajoutées à votre organisation.", "Read more here.": "Lire la suite ici.", + "Reading": "En lisant", "Really reset all passwords? This will sign you out of SEED.": "Réinitialiser tous les mots de passe? Cela vous déconnectera de SEED.", "Recent Sale Date": "Date de vente récente", "Recognize Empty": "Reconnaître vide", "Record from Source": "Enregistrement de la source", "Refresh Metadata": "Actualiser les métadonnées", "Refresh Metadata of Selected Properties and Tax Lots": "Actualiser les métadonnées des propriétés et terrains d'impôt sélectionnés", + "Refresh Updated Timestamp": "Actualiser l'horodatage mis à jour", "Reject": "Rejeter", "Related Properties": "Propriétés connexes", "Related Tax Lots": "Lots d'impôt connexes", @@ -1137,8 +1205,10 @@ "Row 4": "Rangée 4", "Row 5": "Rangée 5", "Rules for a single field must have the same Data Type. Note that some Data Types are not available to certain Condition Checks.": "Les règles pour un seul champ doivent avoir le même type de données. Notez que certains types de données ne sont pas disponibles pour certaines vérifications de condition.", + "Run": "Courir", "Run Analysis": "Exécuter l'analyse", "Run Author": "Exécuter l'auteur", + "Run Check": "Vérifier", "Run Cycle": "Cycle d'exécution", "Run Data Quality Check, available in the Actions dropdown, to auto-populate the \"Passed Checks\" column.": "Exécutez le contrôle de la qualité des données, disponible dans la liste déroulante Actions, pour remplir automatiquement la colonne « Contrôles réussis ».", "Run Date": "Date d'exécution", @@ -1151,6 +1221,7 @@ "SEED Home": "Accueil SEED", "SEED Platform": "Plate-forme SEED", "SEED Platform™": "SEED Platform™", + "SEED Unique Benchmark ID Column": "Colonne d'identification unique de référence SEED", "SEED Unique Benchmark ID Fieldname": "Unique nom de champ pour Benchmark ID SEED", "SEED doesn't currently support that file format": "SEED ne prend pas actuellement en charge ce format de fichier", "SEED is easy, flexible, and cost effective software designed to help organizations clean, manage and share information about large portfolios of buildings. SEED is a free, open source web application that you can use privately. While SEED was originally designed to help cities and States implement benchmarking programs for public or private buildings, it has the potential to be useful for many other activities by public entities, efficiency programs and private companies.": "SEED est un logiciel simple, flexible et rentable conçu pour aider les entreprises à nettoyer, gérer et partager des informations sur les grands portefeuilles de bâtiments. SEED est une application Web open source gratuite que vous pouvez utiliser en privé. Bien que SEED ait été conçue à l'origine pour aider les villes et les États à mettre en œuvre des programmes de benchmarking pour les bâtiments publics ou privés, il est susceptible d'être utile pour de nombreuses autres activités par des entités publiques, des programmes d'efficacité et des entreprises privées.", @@ -1229,6 +1300,7 @@ "Salesforce Field Name": "Nom du champ Salesforce", "Salesforce Instance URL": "Salesforce URL", "Salesforce Integration": "Intégration de Salesforce", + "Salesforce URL": "URL Salesforce", "Salesforce Unique Benchmark ID Fieldname": "Unique nom de champ pour Benchmark ID Salesforce", "Salesforce connection successful": "Connexion à Salesforce réussie", "Save": "Enregistrer", @@ -1303,13 +1375,16 @@ "Snapshot projects are useful when you want to freeze the building data at a moment in time.": "Les projets Snapshot sont utiles lorsque vous souhaitez geler les données de construction à un moment donné.", "Sorry!": "Désolée!", "Sorting By (in order)": "Trier Par (dans l'ordre)", + "Source": "Source", "Source EUI": "IUE source", "Source EUI Weather Normalized": "IUE source météo normalisée", "Source Energy Use Intensity": "Intensité d'utilisation de l'énergie à la source", "Space Alerts": "Alertes spatiales", + "Start Time": "Heure de début", "State": "État", "State (Property)": "Etat (Propriété)", "State (Tax Lot)": "État (lot d'impôt)", + "Status Label Benchmark Field": "Champ de référence pour l'étiquette d'état", "Step 1: Add Access Levels": "Étape 1: ajouter des niveaux d'accès", "Step 2: Upload Access Level Instances": "Étape 2: Télécharger les instances de niveau d'accès", "Sub-Org Name": "Nom de la sous-organisation", @@ -1327,6 +1402,7 @@ "Switch Profiles": "Changer de profil", "System": "Système", "System Type": "Type de système", + "Systems & Services": "Systèmes et services", "TAXLOT_MATCHING_FIELDS_REQUIREMENT": "Au moins l'un des domaines fiscal Lot suivants est requis", "THERMAL_CONV_ASSUMPTION_TITLE": "Choix de Conversion Thermique", "TOTAL_EUI_HELP": "Le seuil EUI total pour le bâtiment (comprend l'électricité, le gaz, etc.)", @@ -1369,6 +1445,8 @@ "Thank you for your interest in joining the Buildings Performance Database. Our site is in beta and your request is being reviewed for access.": "Merci de votre intérêt pour vous joindre à la Base de données sur les performances des bâtiments. Notre site est en version bêta et votre demande est en cours d'examen pour l'accès.", "Thank you!": "Merci!", "The Building Energy Data Exchange Specification (BEDES, pronounced \"beads\" or \/bi:ds\/) is designed to support analysis of the measured energy performance of commercial, multifamily, and residential buildings, by providing a common data format, definitions, and an exchange protocol for building characteristics, efficiency measures, and energy use.": "La Spécification d'Échange de Données Energétiques du Bâtiment (BEDES, prononcée \/bi:ds\/) est conçue pour soutenir l'analyse de la performance énergétique mesurée des bâtiments commerciaux, multifamiliaux et résidentiels, en fournissant un format de données commun, des définitions et un protocole d'échange pour les caractéristiques du bâtiment, les mesures d'efficacité et l'utilisation de l'énergie.", + "The Client ID from your Salesforce Connected App": "L'identifiant client de votre application connectée Salesforce", + "The Client Secret from your Salesforce Connected App": "Le secret client de votre application connectée Salesforce", "The GeoJSON will expose all populated property and taxlot columns": "Le GeoJSON exposera toutes les colonnes de propriété et de lot fiscal renseignées", "The Open Source code is available on the Github organization SEED-Platform:": "Le code Open Source est disponible sur l'organisation Github SEED-Platform:", "The Public Feed functionality is currently": "La fonctionnalité Public Feed est actuellement", @@ -1378,6 +1456,7 @@ "The SEED team": "L'équipe SEED", "The Standard Energy Efficiency Data (SEED)™ Platform is a software application that helps organizations easily manage data on the energy performance of large groups of buildings. Users can combine data from multiple sources, clean and validate it, and share the information with others. The software application provides an easy, flexible, and cost-effective method to improve the quality and availability of data to help demonstrate the economic and environmental benefits of energy efficiency, to implement programs, and to target investment activity.": "La Plate-forme SEED™ (Standard Energy Efficiency Data) est une application logicielle qui aide les entreprises à gérer facilement les données sur la performance énergétique de grands groupes de bâtiments. Les utilisateurs peuvent combiner les données provenant de plusieurs sources, les nettoyer et les valider, et partager les informations avec les autres. L'application logicielle fournit une méthode simple, flexible et rentable pour améliorer la qualité et la disponibilité des données afin de démontrer les avantages économiques et environnementaux de l'efficacité énergétique, de mettre en œuvre des programmes et de cibler l'activité d'investissement.", "The UBID Threshold defines the minimum Jaccard Index value required for a UBID Comparison to be considered acceptable. When the threshold is satisfied, the incoming property can be merged with the existing property.": "Le seuil UBID définit la valeur minimale de l'indice Jaccard requise pour qu'une comparaison UBID soit considérée comme acceptable. Lorsque le seuil est atteint, la propriété entrante peut être fusionnée avec la propriété existante.", + "The URL of your Salesforce instance": "L'URL de votre instance Salesforce", "The comparison relies on the Jaccard Index, which ranges from 0.0 (no match) to 1.0 (perfect match).": "La comparaison repose sur l'indice de Jaccard, qui varie de 0.0 (aucune superposition) à 1.0 (correspondance parfaite).", "The email supports brace templating to pull in data from the SEED property record. For example, the snippet below will replace the latitude and longitude from the SEED record. Other fields can be added, but make sure to use the SEED field name not the display name.": "L'e-mail prend en charge les modèles d'accolades pour extraire les données de l'enregistrement de propriété SEED. Par exemple, l'extrait ci-dessous remplacera la latitude et la longitude de l'enregistrement SEED. D'autres champs peuvent être ajoutés, mais assurez-vous d'utiliser le nom du champ SEED et non le nom d'affichage.", "The following Property fields are required.": "Les champs de propriété suivants sont obligatoires.", @@ -1424,6 +1503,7 @@ "Two UBIDs are required for comparison. Click Custom UBIDs to continue.": "Deux UBID sont nécessaires pour la comparaison. Cliquez sur UBID personnalisés pour continuer.", "Two-Factor Authentication": "Authentification à deux facteurs", "Type": "Type", + "Type - Source - Source ID": "Type - Source - ID de la source", "Type of Submittal": "Type de soumission", "Type of Submittal:": "Type de soumission:", "U.S.\n Department of Energy": "Département Américain de l'Énergie", @@ -1459,6 +1539,7 @@ "USING_DEFAULT_UNITS_WARNING": "Pour les colonnes avec des paramètres d'unité, les unités par défaut seront utilisées pour les conversions.", "Unexpected Portfolio Summary Calculations?": "Calculs de résumé de portefeuille inattendus ?", "Uniformat Category": "Catégorie Uniformat", + "Unit": "Unité", "Units": "Unités", "Unknown": "Inconnue", "Unmerge Last": "Défaire la fusion Dernière", @@ -1485,10 +1566,12 @@ "Upload Audit Template XML": "Téléchargement Audit Template XML", "Upload BuildingSync Data": "Télécharger les données du BuildingSync", "Upload Green Button Data": "Télécharger les données du Green Button", + "Upload Meter Readings": "Télécharger les lectures du compteur", "Upload Portfolio Manager Data": "Télécharger les données du Portfolio Manager", "Upload Single ESPM Property": "Télécharger une propriété ESPM", "Upload a Spreadsheet": "Télécharger une feuille de calcul", "Upload another energy data file": "Télécharger un autre fichier de données d'énergie", + "Upload failed": "Échec du chargement", "Upload your Buildings List": "Téléchargez votre liste de bâtiments", "Upload your buildings list": "Télécharger votre liste de bâtiments", "Upload your data": "Télécharger vos données", @@ -1513,12 +1596,17 @@ "Version": "Version", "View Compliance Tracking": "Afficher le suivi de la conformité", "View Project": "Voir le projet", + "View Readings": "Consulter les lectures", "View by Property": "Examiner par propriété", "View by Tax Lot": "Examiner par lot d'impôt", "View my properties": "Voir mes propriétés", "View\/Hide Terms": "Afficher\/Masquer les termes", "Viewer": "Spectateur", + "Views Count": "Nombre de vues", + "Views Missing Gross Floor Area": "Vues manquantes Surface brute au sol", + "Views Missing Site EUI": "Vues manquantes Site EUI", "Violation Label": "Étiquette de Violation", + "Virtual": "Virtuel", "Visit": "Visitez", "Visit the BETTER website to run a portfolio analysis": "Visitez le site Web de BETTER pour effectuer une analyse de portefeuille", "Visualization Settings": "Paramètres de visualisation", @@ -1544,6 +1632,7 @@ "What type of file would you like to upload?": "Quel type de fichier souhaitez-vous télécharger?", "When checked, properties\/taxlots will be matched to existing cycles based on the 'year_ending' column or the default cycle if there is no match": "Lorsque cette case est cochée, les propriétés\/taxlots seront appariés aux cycles existants en fonction de la colonne 'year_ending' ou du cycle par défaut s'il n'y a pas de correspondance", "Where would you like to move these buildings?": "Où aimeriez-vous déplacer ces bâtiments?", + "Wood": "Bois", "X Axis": "Axe X", "X Axis Column Selections:": "Sélections de colonne sur l'axe X :", "X Axis Column Types for Plotting:": "Types de colonne de l'axe X pour le traçage:", diff --git a/src/@seed/api/groups/groups.service.ts b/src/@seed/api/groups/groups.service.ts index b913b449..fe0ce68f 100644 --- a/src/@seed/api/groups/groups.service.ts +++ b/src/@seed/api/groups/groups.service.ts @@ -1,12 +1,30 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' -import { BehaviorSubject, catchError, map, type Observable, take, tap } from 'rxjs' +import { BehaviorSubject, catchError, map, type Observable, of, take, 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 { OrganizationService } from '../organization' -import type { InventoryGroup, InventoryGroupResponse, InventoryGroupsResponse } from './groups.types' +import type { + GroupDashboard, + GroupDashboardResponse, + GroupMeter, + GroupMetersResponse, + GroupMeterUsageResponse, + GroupPropertiesResponse, + GroupProperty, + GroupSankeyEntry, + GroupSankeyResponse, + GroupService, + GroupServiceDetail, + GroupSystem, + InventoryGroup, + InventoryGroupResponse, + MeterInterval, + MeterReadingDetail, + SystemsByTypeResponse, +} from './groups.types' @Injectable({ providedIn: 'root' }) export class GroupsService { @@ -21,12 +39,13 @@ export class GroupsService { list(orgId: number) { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` this._httpClient - .get(url) + .get(url) .pipe( take(1), - map(({ data }) => { - this._groups.next(data) - return data + map((data) => { + const groups = Array.isArray(data) ? data : [] + this._groups.next(groups) + return groups }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching groups') @@ -40,12 +59,13 @@ export class GroupsService { const url = `/api/v3/inventory_groups/filter/?organization_id=${orgId}&inventory_type=${type}` const body = { selected: inventoryIds } this._httpClient - .post(url, body) + .post(url, body) .pipe( take(1), - map(({ data }) => { - this._groups.next(data) - return data + map((data) => { + const groups = Array.isArray(data) ? data : [] + this._groups.next(groups) + return groups }), catchError((error: HttpErrorResponse) => { return this._errorService.handleError(error, 'Error fetching groups for inventory') @@ -54,6 +74,16 @@ export class GroupsService { .subscribe() } + fetchGroups(orgId: number): Observable { + const url = `/api/v3/inventory_groups/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map((data) => (Array.isArray(data) ? data : [])), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching groups') + }), + ) + } + create(orgId: number, data: InventoryGroup): Observable { const url = `/api/v3/inventory_groups/?organization_id=${orgId}` return this._httpClient.post(url, data).pipe( @@ -63,7 +93,17 @@ export class GroupsService { return data }), catchError((error: HttpErrorResponse) => { - return this._errorService.handleError(error, 'Error updating group') + return this._errorService.handleError(error, 'Error creating group') + }), + ) + } + + get(orgId: number, id: number): Observable { + const url = `/api/v3/inventory_groups/${id}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group') }), ) } @@ -118,4 +158,264 @@ export class GroupsService { }), ) } + + getById(orgId: number, groupId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group') + }), + ) + } + + getDashboard(orgId: number, groupId: number, cycleId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/dashboard/?organization_id=${orgId}&cycle_id=${cycleId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group dashboard') + }), + ) + } + + getSankeyData(orgId: number, groupId: number, cycleId: number, meterType: string): Observable { + const url = `/api/v3/inventory_groups/${groupId}/dashboard_sankey/?organization_id=${orgId}&cycle_id=${cycleId}&meter_type=${meterType}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching sankey data') + }), + ) + } + + getProperties(orgId: number, groupId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/properties/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group properties') + }), + ) + } + + getMeters(orgId: number, groupId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching group meters') + }), + ) + } + + getMeterUsage(orgId: number, groupId: number, interval: MeterInterval): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meter_usage/?organization_id=${orgId}` + return this._httpClient.post(url, { interval }).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching meter usage') + }), + ) + } + + getMeterReadings(orgId: number, meterId: number): Observable { + const url = `/api/v4/meters/${meterId}/readings/?organization_id=${orgId}` + return this._httpClient.get<{ status: string; data: MeterReadingDetail[]; count: number }>(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching meter readings') + }), + ) + } + + getMeterReadingsCount(orgId: number, meterId: number): Observable { + const url = `/api/v4/meters/${meterId}/readings/count/?organization_id=${orgId}` + return this._httpClient.get<{ status: string; data: { count: number } }>(url).pipe( + map(({ data }) => data.count), + catchError(() => of(0)), + ) + } + + createMeter(orgId: number, groupId: number, meterData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/?organization_id=${orgId}` + return this._httpClient.post(url, meterData).pipe( + tap(() => { + this._snackBar.success('Meter created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating meter') + }), + ) + } + + // Systems CRUD + getSystemsByType(orgId: number, groupId: number): Observable> { + const url = `/api/v3/inventory_groups/${groupId}/systems/systems_by_type/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching systems') + }), + ) + } + + createSystem(orgId: number, groupId: number, systemData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/?organization_id=${orgId}` + return this._httpClient.post(url, systemData).pipe( + tap(() => { + this._snackBar.success('System created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating system') + }), + ) + } + + updateSystem(orgId: number, groupId: number, systemId: number, systemData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/?organization_id=${orgId}` + return this._httpClient.put(url, systemData).pipe( + tap(() => { + this._snackBar.success('System updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating system') + }), + ) + } + + deleteSystem(orgId: number, groupId: number, systemId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this._snackBar.success('System deleted successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting system') + }), + ) + } + + // Services CRUD + getServices(orgId: number, groupId: number, systemId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/?organization_id=${orgId}` + return this._httpClient.get<{ status: string; data: GroupService[] }>(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching services') + }), + ) + } + + createService(orgId: number, groupId: number, systemId: number, serviceData: Partial): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/?organization_id=${orgId}` + return this._httpClient.post(url, serviceData).pipe( + tap(() => { + this._snackBar.success('Service created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating service') + }), + ) + } + + updateService( + orgId: number, + groupId: number, + systemId: number, + serviceId: number, + serviceData: Partial, + ): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` + return this._httpClient.put(url, serviceData).pipe( + tap(() => { + this._snackBar.success('Service updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating service') + }), + ) + } + + deleteService(orgId: number, groupId: number, systemId: number, serviceId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this._snackBar.success('Service deleted successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting service') + }), + ) + } + + getServiceDetail(orgId: number, groupId: number, systemId: number, serviceId: number) { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/?organization_id=${orgId}` + return this._httpClient.get(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching service detail') + }), + ) + } + + createServiceMeters( + orgId: number, + groupId: number, + systemId: number, + serviceId: number, + data: { direction: string; type: string; property_ids: number[] }, + ): Observable { + const url = `/api/v3/inventory_groups/${groupId}/systems/${systemId}/services/${serviceId}/create_meters/?organization_id=${orgId}` + return this._httpClient.post(url, data).pipe( + tap(() => { + this._snackBar.success('Meters created successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating service meters') + }), + ) + } + + updateMeter( + orgId: number, + groupId: number, + meterId: number, + data: { alias?: string; connection_config?: Record }, + ): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/${meterId}/?organization_id=${orgId}` + return this._httpClient.put(url, data).pipe( + tap(() => { + this._snackBar.success('Meter updated successfully') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating meter') + }), + ) + } + + deleteMeter(orgId: number, groupId: number, meterId: number): Observable { + const url = `/api/v3/inventory_groups/${groupId}/meters/${meterId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this._snackBar.success('Meter deleted successfully') + }), + catchError((error: HttpErrorResponse) => { + // Dev proxy may fail to parse 204 No Content (status 0). The delete succeeds server-side. + if (error.status === 0) { + this._snackBar.success('Meter deleted successfully') + return of(null) + } + return this._errorService.handleError(error, 'Error deleting meter') + }), + ) + } + + uploadMeterReadings(orgId: number, importFileId: number, meterId: number): Observable<{ message: string }> { + const url = `/api/v3/import_files/${importFileId}/system_meter_upload/?organization_id=${orgId}` + return this._httpClient.post<{ message: string }>(url, { meter_id: meterId }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error uploading meter readings') + }), + ) + } } diff --git a/src/@seed/api/groups/groups.types.ts b/src/@seed/api/groups/groups.types.ts index 1a369b17..559a9907 100644 --- a/src/@seed/api/groups/groups.types.ts +++ b/src/@seed/api/groups/groups.types.ts @@ -26,18 +26,143 @@ export type GroupSystem = { cooling_capacity: number | null; count: number; des_type: string; + efficiency: number | null; + energy_capacity: number | null; + evse_type: string; group_id: number; heating_capacity: number | null; id: number; mode: string; name: string; + power: number | null; + power_capacity: number | null; services: GroupService[]; type: string; + voltage: number | null; } export type GroupService = { - emission_factor: number; + emission_factor: number | null; id: number; name: string; properties: number[]; } + +export type SystemsByTypeResponse = { + status: string; + data: Record; +} + +export type SystemType = 'DES' | 'EVSE' | 'Battery' | 'Aggregate Meter' + +export type DesType = 'Boiler' | 'Chiller' | 'CHP' + +export type EvseType = 'Level1-120V' | 'Level2-240V' | 'Level3-DC Fast' + +export type GroupDashboardResponse = { + status: string; + data: GroupDashboard; +} + +export type GroupDashboard = { + 'Gross Floor Area': number | null; + 'Site EUI': number | null; + 'Views Count': number; + 'Views Missing Site EUI': number; + 'Views Missing Gross Floor Area': number; + importing_total: Record; + exporting_total: Record; +} + +export type GroupSankeyResponse = { + status: string; + data: GroupSankeyEntry[]; +} + +export type GroupSankeyEntry = { + from: string; + to: string; + flow: number | null; +} + +export type GroupPropertiesResponse = { + status: string; + data: GroupProperty[]; +} + +export type GroupProperty = { + property_id: number; + property_display_name: string; +} + +export type GroupMeterUsageResponse = { + status: string; + data: { + readings: Record[]; + column_defs: { field: string; headerName?: string }[]; + }; +} + +export type GroupMetersResponse = { + status: string; + data: GroupMeter[]; +} + +export type GroupMeterConfig = { + direction: 'imported' | 'exported'; + use: 'outside' | 'using' | 'offering'; + connection: 'outside' | 'service'; + group_id: number | null; + system_id: number | null; + service_id: number | null; +} + +export type GroupMeter = { + id: number; + type: string; + alias: string; + source: string; + source_id: string; + connection_type: string; + property_id: number | null; + property_display_field: string | null; + view_id: number | null; + system_id: number | null; + system_name: string | null; + service_id: number | null; + service_name: string | null; + service_group: number | null; + scenario_id: number | null; + scenario_name: string | null; + is_virtual: boolean; + config: GroupMeterConfig; +} + +export type MeterInterval = 'Exact' | 'Month' | 'Year' + +export type MeterReadingDetail = { + start_time: string; + end_time: string; + reading: number | null; + source_unit: string | null; + conversion_factor: number; +} + +export type GroupServiceDetail = { + id: number; + system_name: string; + name: string; + service_meters: { + in: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; + out: { meter_id: number; meter_alias: string; has_meter_data: boolean }[]; + }; + properties: { + property_id: number; + property_view_id: number; + property_display_name: string; + meter_id: number; + meter_alias: string; + meter_type: string; + has_meter_data: boolean; + }[]; +} diff --git a/src/@seed/api/inventory/inventory.service.ts b/src/@seed/api/inventory/inventory.service.ts index c9f9a8b1..ba2cd914 100644 --- a/src/@seed/api/inventory/inventory.service.ts +++ b/src/@seed/api/inventory/inventory.service.ts @@ -2,7 +2,7 @@ import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { BehaviorSubject, catchError, map, take, tap, throwError } from 'rxjs' +import { BehaviorSubject, catchError, map, of, take, tap, throwError } from 'rxjs' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { @@ -144,7 +144,12 @@ export class InventoryService { deleteColumnListProfile(orgId: number, id: number): Observable { const url = `/api/v3/column_list_profiles/${id}/?organization_id=${orgId}` return this._httpClient.delete(url).pipe( + map((): null => null), catchError((error: HttpErrorResponse) => { + // Dev proxy may fail to parse 204 No Content (status 0). The delete succeeds server-side. + if (error.status === 0) { + return of(null) + } return this._errorService.handleError(error, 'Error deleting column list profile') }), ) diff --git a/src/@seed/components/ag-grid/autocomplete.component.ts b/src/@seed/components/ag-grid/autocomplete.component.ts index ff435a54..4c568aa9 100644 --- a/src/@seed/components/ag-grid/autocomplete.component.ts +++ b/src/@seed/components/ag-grid/autocomplete.component.ts @@ -24,12 +24,9 @@ export class AutocompleteCellComponent implements ICellEditorAngularComp, AfterV this.inputCtrl.setValue(params.value as string) this.filteredOptions = [...this.options] this.inputCtrl.valueChanges.subscribe((value) => { - // autocomplete this.filteredOptions = this.options.filter((option) => { return option.toLowerCase().startsWith(value.toLowerCase()) }) - // update after each keystroke - this.params.node.setDataValue(this.params.column.getId(), value) }) } diff --git a/src/@seed/components/column-profiles/column-profiles.component.ts b/src/@seed/components/column-profiles/column-profiles.component.ts index efe94a3c..c2029643 100644 --- a/src/@seed/components/column-profiles/column-profiles.component.ts +++ b/src/@seed/components/column-profiles/column-profiles.component.ts @@ -44,6 +44,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { updateCLP$ = new Subject() updateOrgUserSettings$ = new Subject() rowData: ProfileColumn[] = [] + private _rowSelectedTimer: ReturnType gridOptions: GridOptions = { rowSelection: { @@ -241,10 +242,15 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { } onRowSelected(event: RowSelectedEvent) { - if (event.source !== 'api') { + // Ignore programmatic selection and header checkbox intermediate events + if (event.source === 'api') return + + // Defer to let AG Grid finish processing all row selections (e.g., header checkbox "select all") + if (this._rowSelectedTimer) clearTimeout(this._rowSelectedTimer) + this._rowSelectedTimer = setTimeout(() => { const selectedRows = new Set(this.gridApi.getSelectedRows().map((r: ProfileColumn) => r.id)) this.setRowData(selectedRows) - } + }, 50) } selectProfile(event: MatSelectChange) { diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index ad24c570..4ffa67fd 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -209,18 +209,9 @@ export class MapDataComponent implements OnChanges, OnDestroy { } copyHeadersToSeed() { - const { suggested_column_mappings } = this.mappingSuggestions - const columns = this.getColumns() - const columnMap: Record = columns.reduce( - (acc, { column_name, display_name }) => ({ ...acc, [column_name]: display_name }), - {}, - ) - this.gridApi.forEachNode((node: RowNode<{ from_field: string }>) => { const fileHeader = node.data.from_field - const suggestedColumnName = suggested_column_mappings[fileHeader][1] - const displayName = columnMap[suggestedColumnName] ?? fileHeader - node.setDataValue('to_field_display_name', displayName) + node.setDataValue('to_field_display_name', fileHeader) }) } @@ -237,6 +228,7 @@ export class MapDataComponent implements OnChanges, OnDestroy { node.setDataValue('to_field', mapping.to_field) node.setDataValue('from_units', mapping.from_units) node.setDataValue('to_table_name', toTableMap[mapping.to_table_name]) + node.setDataValue('omit', mapping.is_omitted) }) } diff --git a/src/app/modules/insights/default-reports/default-reports.component.ts b/src/app/modules/insights/default-reports/default-reports.component.ts index 83066787..16f29e21 100644 --- a/src/app/modules/insights/default-reports/default-reports.component.ts +++ b/src/app/modules/insights/default-reports/default-reports.component.ts @@ -344,7 +344,7 @@ export class DefaultReportsComponent implements OnInit, OnDestroy { this.scatterChart.update() this.barChart.data.labels = [] this.barChart.data.datasets[0].data = [] - ;(this.barChart.data.datasets[0].backgroundColor as string[]) = [] + ;(this.barChart.data.datasets[0] as { backgroundColor?: unknown }).backgroundColor = [] this.barChart.update() } @@ -480,7 +480,7 @@ export class DefaultReportsComponent implements OnInit, OnDestroy { this.barChart.options.scales.y.max = undefined this.barChart.data.labels = data.chart_data.map((d: AggregatedChartPoint) => d.y as string) this.barChart.data.datasets[0].data = data.chart_data.map((d: AggregatedChartPoint) => d.x as number) - ;(this.barChart.data.datasets[0].backgroundColor as string[]) = data.chart_data.map( + ;(this.barChart.data.datasets[0] as { backgroundColor?: unknown }).backgroundColor = data.chart_data.map( (d: AggregatedChartPoint) => colorMap.get(d.yr_e) ?? '#458cc8', ) this.barChart.update() diff --git a/src/app/modules/insights/property-insights/property-insights.component.ts b/src/app/modules/insights/property-insights/property-insights.component.ts index 598772cd..04b9d5e5 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -543,7 +543,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { setDatasetColor() { for (const ds of this.chart.data.datasets) { - ds.backgroundColor = this.colors[ds.label] + ;(ds as { backgroundColor?: string }).backgroundColor = this.colors[ds.label] } } diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html new file mode 100644 index 00000000..f587dc22 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.html @@ -0,0 +1,145 @@ + + + +
+ +
+ Cycle + + @for (cycle of cycles; track cycle.cycle_id) { + {{ cycle.name }} + } + +
+ + @if (loading) { +
Loading...
+ } @else if (dashboard) { + +
+
+
Gross Floor Area
+
{{ dashboard['Gross Floor Area'] ?? 0 | number }}
+
+
+
Site EUI
+
{{ dashboard['Site EUI'] ?? 0 | number }}
+
+
+
Views Count
+
{{ dashboard['Views Count'] | number }}
+
+
+
Missing Site EUI
+
{{ dashboard['Views Missing Site EUI'] | number }}
+
+
+
Missing Gross Floor Area
+
{{ dashboard['Views Missing Gross Floor Area'] | number }}
+
+
+ + + @if (meterTypes.length) { + + +
+ +
+
Importing Totals
+ @if (dashboard.importing_total | keyvalue; as imports) { + @for (entry of imports; track entry.key) { +
+ {{ entry.key }} + {{ entry.value | number }} +
+ } + } @else { +
No importing data
+ } +
+ + +
+
Exporting Totals
+ @if (dashboard.exporting_total | keyvalue; as exports) { + @for (entry of exports; track entry.key) { +
+ {{ entry.key }} + {{ entry.value | number }} +
+ } + } @else { +
No exporting data
+ } +
+
+ + + + +
+
+ Energy Type + + @for (type of meterTypes; track type) { + {{ type }} + } + +
+ + @if (sankeyData.length) { + +
+
Energy Flow
+
+ +
+
+ + + + + Troubleshooting Data + + + + + + + + + + + @for (entry of sankeyData; track $index) { + + + + + + } + +
FromToFlow
{{ entry.from }}{{ entry.to }}{{ entry.flow | number: '1.2-2' }}
+
+ } @else { +
No energy flow data for this type and cycle
+ } +
+ } + } @else { +
No dashboard data available. Add properties to this group to see metrics.
+ } +
diff --git a/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts new file mode 100644 index 00000000..e9480001 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/dashboard/dashboard.component.ts @@ -0,0 +1,186 @@ +import { CommonModule } from '@angular/common' +import type { ElementRef, OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { Chart } from 'chart.js/auto' +import { Flow, SankeyController } from 'chartjs-chart-sankey' +import { catchError, filter, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupDashboard, GroupSankeyEntry, OrgCycle } from '@seed/api' +import { GroupsService, MeterTypesService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' + +Chart.register(SankeyController, Flow) + +const SANKEY_COLORS: Record = { + Oil: 'black', + 'Natural Gas': 'red', + Coal: 'gray', + 'Fossil Fuels': '#708090', // slate gray + Electricity: 'blue', + Energy: 'orange', +} + +@Component({ + selector: 'seed-group-dashboard', + templateUrl: './dashboard.component.html', + imports: [CommonModule, MaterialImports, PageComponent], +}) +export class GroupDashboardComponent implements OnDestroy, OnInit { + @ViewChild('sankeyCanvas') sankeyCanvas: ElementRef + private _configService = inject(ConfigService) + private _groupsService = inject(GroupsService) + private _meterTypesService = inject(MeterTypesService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private readonly _unsubscribeAll$ = new Subject() + private _chart: Chart | null = null + private _isDark = false + + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + orgId: number + cycleId: number + cycles: OrgCycle[] = [] + dashboard: GroupDashboard | null = null + sankeyData: GroupSankeyEntry[] = [] + meterType = '' + meterTypes: string[] = [] + loading = true + + ngOnInit() { + this._configService.scheme$ + .pipe( + takeUntil(this._unsubscribeAll$), + tap((scheme) => { + this._isDark = scheme === 'dark' + this._updateChart() + }), + ) + .subscribe() + + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => org?.org_id != null), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._organizationService.getById(this.orgId)), + tap((org) => { + this.cycles = org.cycles + this.cycleId = org.cycles[0]?.cycle_id + }), + switchMap(() => this._meterTypesService.energyMeters$.pipe(take(1))), + tap((energyMeters) => { + this.meterTypes = energyMeters.map((m) => m.name) + if (this.meterTypes.length) { + this.meterType = this.meterTypes.find((t) => t === 'District Chilled Water') ?? this.meterTypes[0] + } + }), + switchMap(() => this.loadDashboard()), + switchMap(() => this.loadSankey()), + ) + .subscribe() + } + + loadDashboard() { + this.loading = true + return this._groupsService.getDashboard(this.orgId, this.groupId, this.cycleId).pipe( + tap((data) => { + this.dashboard = data + this.loading = false + }), + catchError((err) => { + console.error('Dashboard error:', err) + this.dashboard = null + this.loading = false + return of(null) + }), + ) + } + + changeCycle(cycleId: number) { + this.cycleId = cycleId + this.loadDashboard() + .pipe(switchMap(() => this.loadSankey())) + .subscribe() + } + + loadSankey() { + if (!this.meterType) { + this.sankeyData = [] + return of([]) + } + return this._groupsService.getSankeyData(this.orgId, this.groupId, this.cycleId, this.meterType).pipe( + tap((data) => { + this.sankeyData = data + setTimeout(() => { + this._renderChart() + }) + }), + ) + } + + changeMeterType(meterType: string) { + this.meterType = meterType + this.loadSankey().subscribe() + } + + ngOnDestroy() { + this._chart?.destroy() + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + private _getColor(name: string): string { + return SANKEY_COLORS[name] ?? 'green' + } + + private _renderChart() { + if (!this.sankeyCanvas?.nativeElement) return + + const chartData = this.sankeyData.filter((d) => d.flow) + if (this._chart) { + this._chart.destroy() + this._chart = null + } + + if (!chartData.length) return + + const labelColor = this._isDark ? '#e5e7eb' : '#1f2937' + + this._chart = new Chart(this.sankeyCanvas.nativeElement, { + type: 'sankey' as unknown as 'line', + data: { + datasets: [ + { + data: chartData as never, + colorFrom: ((c: { dataset: { data: GroupSankeyEntry[] }; dataIndex: number }) => + this._getColor(c.dataset.data[c.dataIndex]?.from)) as never, + colorTo: ((c: { dataset: { data: GroupSankeyEntry[] }; dataIndex: number }) => + this._getColor(c.dataset.data[c.dataIndex]?.to)) as never, + borderWidth: 2, + borderColor: 'black', + color: labelColor, + } as never, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + }, + }) + } + + private _updateChart() { + if (!this._chart) return + const labelColor = this._isDark ? '#e5e7eb' : '#1f2937' + const dataset = this._chart.data.datasets[0] as unknown as Record + if (dataset) { + dataset.color = labelColor + } + this._chart.update() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html new file mode 100644 index 00000000..16484419 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.html @@ -0,0 +1,13 @@ +
+ + + + + + +
+ +
+
+
+
diff --git a/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts new file mode 100644 index 00000000..8134dc6b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/group-detail-layout.component.ts @@ -0,0 +1,78 @@ +import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import type { MatDrawer } from '@angular/material/sidenav' +import { ActivatedRoute, RouterOutlet } from '@angular/router' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { InventoryGroup } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import type { NavigationItem } from '@seed/components' +import { DrawerService, VerticalNavigationComponent } from '@seed/components' +import { ScrollResetDirective } from '@seed/directives' +import { MaterialImports } from '@seed/materials' +import type { InventoryType } from 'app/modules/inventory/inventory.types' + +@Component({ + selector: 'seed-group-detail-layout', + templateUrl: './group-detail-layout.component.html', + imports: [MaterialImports, RouterOutlet, ScrollResetDirective, VerticalNavigationComponent], +}) +export class GroupDetailLayoutComponent implements AfterViewInit, OnDestroy, OnInit { + @ViewChild('drawer') drawer!: MatDrawer + private _drawerService = inject(DrawerService) + private _activatedRoute = inject(ActivatedRoute) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private readonly _unsubscribeAll$ = new Subject() + + groupId = parseInt(this._activatedRoute.snapshot.paramMap.get('groupId')) + type = this._activatedRoute.snapshot.paramMap.get('type') as InventoryType + orgId: number + group: InventoryGroup + + navigationMenu: NavigationItem[] = [] + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._groupsService.getById(this.orgId, this.groupId)), + tap((group) => { + this.group = group + this.buildNavigation() + }), + ) + .subscribe() + } + + buildNavigation() { + const base = `/${this.type}/groups/${this.groupId}` + this.navigationMenu = [ + { + id: 'group-detail', + title: this.group?.name ?? 'Group', + type: 'group', + children: [ + { id: 'dashboard', link: `${base}/dashboard`, title: 'Dashboard', type: 'basic', exactMatch: true }, + { id: 'properties', link: `${base}/properties`, title: 'Properties', type: 'basic' }, + { id: 'systems', link: `${base}/systems`, title: 'Systems & Services', type: 'basic' }, + { id: 'meters', link: `${base}/meters`, title: 'Meters', type: 'basic' }, + { id: 'map', link: `${base}/map`, title: 'Map', type: 'basic' }, + ], + }, + ] + } + + ngAfterViewInit() { + this._drawerService.setDrawer(this.drawer) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/map/map.component.ts b/src/app/modules/inventory-list/groups/detail/map/map.component.ts new file mode 100644 index 00000000..77415dee --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/map/map.component.ts @@ -0,0 +1,13 @@ +import { Component, inject } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { MapComponent } from '../../../map/map.component' + +@Component({ + selector: 'seed-group-map', + template: '', + imports: [MapComponent], + host: { class: 'flex flex-col flex-auto min-h-0' }, +}) +export class GroupMapComponent { + groupId = Number.parseInt(inject(ActivatedRoute).parent.snapshot.paramMap.get('groupId')) +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html new file mode 100644 index 00000000..f91fd6f6 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.html @@ -0,0 +1,47 @@ +
+ +
Create Meter
+
+ + + + + + What system is this meter associated with? + + @for (system of systems; track system.id) { + {{ system.name }} + } + + Select the system associated with the meter + + + + + Type + + @for (t of meterTypes; track t) { + {{ t }} + } + + Select the meter type from the list + + + + + Alias + + Enter an identifying name for this meter + + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ + + + + + + diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts new file mode 100644 index 00000000..ebf1fe9b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/create-meter-dialog.component.ts @@ -0,0 +1,109 @@ +import { Component, inject, type OnInit } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { tap } from 'rxjs' +import type { GroupSystem, InventoryGroup } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export type CreateMeterDialogData = { + orgId: number; + groupId: number; +} + +@Component({ + selector: 'seed-create-meter-dialog', + templateUrl: './create-meter-dialog.component.html', + imports: [MaterialImports, ReactiveFormsModule], +}) +export class CreateMeterDialogComponent implements OnInit { + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + data = inject(MAT_DIALOG_DATA) + + systems: GroupSystem[] = [] + errorMessage: string | null = null + submitted = false + + meterTypes = [ + 'Coal (anthracite)', + 'Coal (bituminous)', + 'Coke', + 'Diesel', + 'District Chilled Water', + 'District Chilled Water - Absorption', + 'District Chilled Water - Electric', + 'District Chilled Water - Engine', + 'District Chilled Water - Other', + 'District Hot Water', + 'District Steam', + 'Electric', + 'Electric - Grid', + 'Electric - Solar', + 'Electric - Wind', + 'Fuel Oil (No. 1)', + 'Fuel Oil (No. 2)', + 'Fuel Oil (No. 4)', + 'Fuel Oil (No. 5 and No. 6)', + 'Kerosene', + 'Natural Gas', + 'Other', + 'Propane', + 'Wood', + 'Cost', + 'Electric - Unknown', + 'Custom Meter', + 'Potable Indoor', + 'Potable Outdoor', + 'Potable: Mixed Indoor/Outdoor', + ] + + form = new FormGroup({ + system_id: new FormControl(null, Validators.required), + type: new FormControl(null, Validators.required), + alias: new FormControl('', Validators.required), + }) + + ngOnInit() { + this._groupsService + .get(this.data.orgId, this.data.groupId) + .pipe( + tap((group: InventoryGroup) => { + this.systems = group.systems ?? [] + }), + ) + .subscribe() + } + + onSubmit() { + if (this.form.invalid) return + this.submitted = true + this.errorMessage = null + + const meterData = this.form.value + this._groupsService.createMeter(this.data.orgId, this.data.groupId, meterData).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: (err: unknown) => { + this.submitted = false + this.errorMessage = 'Failed to create meter' + if (err !== null && typeof err === 'object') { + const error = err as Record + if (error.error && typeof error.error === 'object') { + const errorDetail = error.error as Record + if (typeof errorDetail.errors === 'string') { + this.errorMessage = errorDetail.errors + } else if (typeof errorDetail.message === 'string') { + this.errorMessage = errorDetail.message + } + } + } + }, + }) + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html new file mode 100644 index 00000000..774d0f90 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.html @@ -0,0 +1,21 @@ +
+
+
Delete Meter
+

+ Are you sure you want to delete the meter {{ meterName }}? +

+

This action cannot be undone. All associated meter readings will also be deleted.

+
+ +
+ + +
+
diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts new file mode 100644 index 00000000..589fbb3b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/delete-meter-dialog.component.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export type DeleteMeterDialogData = { + orgId: number; + groupId: number; + meter: { + id: number; + alias: string; + type: string; + }; +} + +@Component({ + selector: 'seed-delete-meter-dialog', + templateUrl: './delete-meter-dialog.component.html', + imports: [MaterialImports], +}) +export class DeleteMeterDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as DeleteMeterDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + meterName = this._data.meter.alias || `${this._data.meter.type} (ID: ${this._data.meter.id})` + submitted = false + + confirm() { + if (this.submitted) return + this.submitted = true + + this._groupsService.deleteMeter(this._data.orgId, this._data.groupId, this._data.meter.id).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html new file mode 100644 index 00000000..35b72791 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.html @@ -0,0 +1,103 @@ +
+
+
Configure Meter Details
+ +
+ + @if (meter.property_display_field) { + + Property + + + } + + + + Alias + + + + + + + + Flow Direction + + Imported + Exported + + + + + + Connection + + Connected to Outside + Connected to a Service + + + + @if (!loading && connection === 'service') { + + + + Meter Usage + info + + + @for (option of useOptions; track option.value) { + {{ option.display }} + } + + + + + @if (use) { + + System + + @for (system of systemOptions; track system.id) { + {{ system.name }} + } + + + } + + + @if (selectedSystemId !== null) { + + Service + + @for (service of serviceOptions; track service.id) { + {{ service.name }} + } + + + } + } + + @if (loading && connection === 'service') { +
+ +
+ } +
+ + @if (error) { +
{{ error }}
+ } +
+ +
+ + +
+
diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts new file mode 100644 index 00000000..89ae2a64 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/edit-meter-dialog.component.ts @@ -0,0 +1,144 @@ +import type { 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 type { GroupMeterConfig, GroupService, GroupSystem } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export type EditMeterDialogData = { + orgId: number; + groupId: number; + meter: { + id: number; + alias: string; + connection_type: string; + property_id: number | null; + property_display_field: string | null; + system_id: number | null; + service_id: number | null; + config: GroupMeterConfig; + }; +} + +@Component({ + selector: 'seed-edit-meter-dialog', + templateUrl: './edit-meter-dialog.component.html', + imports: [FormsModule, MaterialImports], +}) +export class EditMeterDialogComponent implements OnInit { + private _data = inject(MAT_DIALOG_DATA) as EditMeterDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + meter = this._data.meter + alias = this._data.meter.alias ?? '' + direction: 'imported' | 'exported' = this._data.meter.config?.direction ?? 'imported' + connection: 'outside' | 'service' = this._data.meter.config?.connection ?? 'outside' + use: 'using' | 'offering' | null = this._data.meter.config?.use !== 'outside' ? (this._data.meter.config?.use ?? null) : null + selectedSystemId: number | null = this._data.meter.config?.system_id ?? null + selectedServiceId: number | null = this._data.meter.config?.service_id ?? null + + systemOptions: GroupSystem[] = [] + serviceOptions: GroupService[] = [] + useOptions: { value: string; display: string }[] = [{ value: 'using', display: 'Using a Service' }] + + loading = true + submitted = false + error = '' + + // Property meters can't change "use" — it's always "using" + get isPropertyMeter(): boolean { + return !!this._data.meter.property_id + } + + // System meters can also "offer" a service + get isSystemMeter(): boolean { + return !!this._data.meter.system_id + } + + ngOnInit() { + if (this.isSystemMeter) { + this.useOptions.push({ value: 'offering', display: 'Offering a Service (Total)' }) + } + + // Fetch the current group to get its systems/services + this._groupsService.get(this._data.orgId, this._data.groupId).subscribe((group) => { + this.systemOptions = group.systems ?? [] + // Pre-populate service options from config + if (this.selectedSystemId) { + this.onSystemSelected(false) + } + this.loading = false + }) + } + + onConnectionChanged() { + this.use = null + this.selectedSystemId = null + this.selectedServiceId = null + this.serviceOptions = [] + this.error = '' + + if (this.connection === 'service' && this.isPropertyMeter) { + this.use = 'using' + } + } + + onUseSelected(reset = true) { + if (reset) { + this.selectedSystemId = null + this.selectedServiceId = null + this.serviceOptions = [] + } + + // If "offering", system is already known + if (this.isSystemMeter && this.use === 'offering') { + this.selectedSystemId = this._data.meter.system_id + this.onSystemSelected(reset) + } + } + + onSystemSelected(reset = true) { + if (reset) { + this.selectedServiceId = null + } + const system = this.systemOptions.find((s) => s.id === this.selectedSystemId) + this.serviceOptions = system?.services ?? [] + } + + get formValid(): boolean { + if (!this.direction) return false + if (this.connection === 'outside') return true + return !!this.use && !!this.selectedSystemId && !!this.selectedServiceId + } + + save() { + if (this.submitted || !this.formValid) return + this.submitted = true + this.error = '' + + const config: Record = { + direction: this.direction, + use: this.connection === 'outside' ? 'outside' : this.use, + } + + if (this.connection === 'service' && this.selectedServiceId) { + config.service_id = this.selectedServiceId + } + + const payload: { alias?: string; connection_config: Record } = { connection_config: config } + if (this.alias !== this._data.meter.alias) { + payload.alias = this.alias + } + + this._groupsService.updateMeter(this._data.orgId, this._data.groupId, this._data.meter.id, payload).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/meter-readings-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/meter-readings-dialog.component.ts new file mode 100644 index 00000000..add9f1a6 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/meter-readings-dialog.component.ts @@ -0,0 +1,85 @@ +import { AsyncPipe } from '@angular/common' +import { Component, inject, type OnInit } 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 { AllCommunityModule, ModuleRegistry } from 'ag-grid-community' +import type { GroupMeter, MeterReadingDetail } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' + +ModuleRegistry.registerModules([AllCommunityModule]) + +export type MeterReadingsDialogData = { + orgId: number; + meter: GroupMeter; +} + +@Component({ + selector: 'seed-meter-readings-dialog', + template: ` +
+ +
Readings: {{ data.meter.alias }}
+
+ + + @if (loading) { +

Loading readings...

+ } @else if (readings.length === 0) { +

No readings found for this meter.

+ } @else { +

{{ readings.length }} readings

+
+ +
+ } +
+
+ +
+ `, + imports: [AgGridAngular, AsyncPipe, MaterialImports], +}) +export class MeterReadingsDialogComponent implements OnInit { + private _configService = inject(ConfigService) + private _groupsService = inject(GroupsService) + dialogRef = inject(MatDialogRef) + data = inject(MAT_DIALOG_DATA) + gridTheme$ = this._configService.gridTheme$ + + loading = true + readings: MeterReadingDetail[] = [] + columnDefs: ColDef[] = [ + { headerName: 'Start Time', field: 'start_time', flex: 1 }, + { headerName: 'End Time', field: 'end_time', flex: 1 }, + { headerName: 'Reading', field: 'reading', flex: 1 }, + { headerName: 'Unit', field: 'source_unit', width: 100 }, + ] + + defaultColDef: ColDef = { + sortable: true, + filter: true, + resizable: true, + } + + ngOnInit() { + this._groupsService.getMeterReadings(this.data.orgId, this.data.meter.id).subscribe({ + next: (readings) => { + this.readings = readings + this.loading = false + }, + error: () => { + this.readings = [] + this.loading = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html new file mode 100644 index 00000000..143180eb --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.html @@ -0,0 +1,52 @@ +
+
+
Add Meter Readings to {{ meterName }}
+ + @if (state === 'upload') { +
+

Upload a CSV or XLSX file with columns: Start Date, End Date, Usage Units, Reading.

+ +
+ + +
+ + @if (selectedFile) { +
+ + {{ selectedFile.name }} +
+ } + + @if (invalidExtension) { +
Invalid file type. Please upload a CSV or XLSX file.
+ } +
+ } + + @if (state === 'processing') { +
+ +

Processing meter readings...

+
+ } + + @if (state === 'confirmation') { +
+

{{ confirmationMessage }}

+
+ } +
+ +
+ @if (state === 'upload') { + + + } @else if (state === 'confirmation') { + + } +
+
diff --git a/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts new file mode 100644 index 00000000..2af155b2 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/dialogs/upload-readings-dialog.component.ts @@ -0,0 +1,102 @@ +import { HttpClient } from '@angular/common/http' +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { catchError, of, switchMap, tap } from 'rxjs' +import { GroupsService, OrganizationService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + +export type UploadReadingsDialogData = { + orgId: number; + groupId: number; + meter: { + id: number; + alias: string; + type: string; + }; +} + +@Component({ + selector: 'seed-upload-readings-dialog', + templateUrl: './upload-readings-dialog.component.html', + imports: [MaterialImports], +}) +export class UploadReadingsDialogComponent implements OnInit { + private _data = inject(MAT_DIALOG_DATA) as UploadReadingsDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _httpClient = inject(HttpClient) + private _snackBar = inject(SnackBarService) + + meterName = this._data.meter.alias || `${this._data.meter.type} (ID: ${this._data.meter.id})` + state: 'upload' | 'processing' | 'confirmation' = 'upload' + confirmationMessage = '' + selectedFile: File | null = null + invalidExtension = false + uploadSucceeded = false + orgId: number + + ngOnInit() { + this.orgId = this._data.orgId + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext !== 'csv' && ext !== 'xlsx') { + this.invalidExtension = true + this.selectedFile = null + return + } + + this.invalidExtension = false + this.selectedFile = file + } + + upload() { + if (!this.selectedFile) return + this.state = 'processing' + this.uploadSucceeded = false + + const datasetName = `Meter Readings Upload - ${new Date().toISOString()}` + + // Step 1: Create dataset + this._httpClient + .post<{ id: number }>(`/api/v3/datasets/?organization_id=${this.orgId}`, { name: datasetName }) + .pipe( + // Step 2: Upload file + switchMap((dataset) => { + const formData = new FormData() + formData.append('file', this.selectedFile) + formData.append('import_record', dataset.id.toString()) + formData.append('source_type', 'Meter Data') + return this._httpClient.post<{ import_file_id: number }>(`/api/v3/upload/?organization_id=${this.orgId}`, formData) + }), + // Step 3: Process meter readings + switchMap((uploadResult) => { + return this._groupsService.uploadMeterReadings(this.orgId, uploadResult.import_file_id, this._data.meter.id) + }), + tap(() => { + this.uploadSucceeded = true + }), + catchError((error: { error?: { message?: string }; message?: string }) => { + this.uploadSucceeded = false + const message = error?.error?.message || error?.message || 'Upload failed' + return of({ message: `Failure: ${message}` }) + }), + ) + .subscribe((result) => { + this.state = 'confirmation' + this.confirmationMessage = result.message + }) + } + + dismiss() { + this._dialogRef.close(this.uploadSucceeded) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.html b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html new file mode 100644 index 00000000..1ceafc90 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.html @@ -0,0 +1,63 @@ + + + +
+ +
+ +
Note: Meters are labeled with the following format: "Type - Source - Source ID".
+
+ @if (loadingMeters) { +
Loading meters...
+ } @else if (meters.length === 0) { +
No meters in this group
+ } @else { + + } + + + + +
+
Readings
+ + @for (i of intervals; track i) { + {{ i }} + } + +
+ + @if (loadingReadings) { +
Loading readings...
+ } @else if (readings.length) { + + } +
diff --git a/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts new file mode 100644 index 00000000..36da2876 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/meters/meters.component.ts @@ -0,0 +1,305 @@ +import { AsyncPipe } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { ActivatedRoute, Router } from '@angular/router' +import { AgGridAngular } from 'ag-grid-angular' +import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent, SelectionChangedEvent } from 'ag-grid-community' +import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupMeter, MeterInterval } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import type { CreateMeterDialogData } from './dialogs/create-meter-dialog.component' +import { CreateMeterDialogComponent } from './dialogs/create-meter-dialog.component' +import type { DeleteMeterDialogData } from './dialogs/delete-meter-dialog.component' +import { DeleteMeterDialogComponent } from './dialogs/delete-meter-dialog.component' +import type { EditMeterDialogData } from './dialogs/edit-meter-dialog.component' +import { EditMeterDialogComponent } from './dialogs/edit-meter-dialog.component' +import type { MeterReadingsDialogData } from './dialogs/meter-readings-dialog.component' +import { MeterReadingsDialogComponent } from './dialogs/meter-readings-dialog.component' +import type { UploadReadingsDialogData } from './dialogs/upload-readings-dialog.component' +import { UploadReadingsDialogComponent } from './dialogs/upload-readings-dialog.component' + +ModuleRegistry.registerModules([AllCommunityModule]) + +@Component({ + selector: 'seed-group-meters', + templateUrl: './meters.component.html', + imports: [AgGridAngular, AsyncPipe, MaterialImports, PageComponent], +}) +export class GroupMetersComponent implements OnDestroy, OnInit { + private _configService = inject(ConfigService) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + private _dialog = inject(MatDialog) + + gridTheme$ = this._configService.gridTheme$ + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + inventoryType = this._getInventoryType() + orgId: number + meters: GroupMeter[] = [] + meterGridApi: GridApi + allReadings: Record[] = [] + allReadingColumnDefs: ColDef[] = [] + readings: Record[] = [] + readingColumnDefs: ColDef[] = [] + selectedMeter: GroupMeter | null = null + interval: MeterInterval = 'Month' + intervals: MeterInterval[] = ['Exact', 'Month', 'Year'] + loadingMeters = true + loadingReadings = false + + meterColumnDefs: ColDef[] = [ + { headerName: 'ID', field: 'id', width: 80 }, + { headerName: 'Type', field: 'type', flex: 1 }, + { headerName: 'Alias', field: 'alias', flex: 1 }, + { headerName: 'Source', field: 'source', width: 130 }, + { headerName: 'Connection Type', field: 'connection_type', width: 150 }, + { + headerName: 'Property', + field: 'property_display_field', + flex: 1, + cellRenderer: (params: { value: string; data: GroupMeter }) => { + if (!params.value || !params.data?.view_id) return params.value ?? '' + return `${params.value}` + }, + }, + { headerName: 'System', field: 'system_name', width: 130 }, + { + headerName: 'Connection', + field: 'service_name', + width: 140, + cellRenderer: (params: { value: string; data: GroupMeter }) => { + if (!params.value || !params.data?.service_group) return params.value ?? '' + return `${params.value}` + }, + }, + { headerName: 'Virtual', field: 'is_virtual', width: 90 }, + { + headerName: 'Actions', + field: 'actions', + width: 180, + sortable: false, + filter: false, + cellRenderer: () => { + return `
+ + + + +
` + }, + }, + ] + + defaultColDef: ColDef = { + sortable: true, + filter: true, + resizable: true, + } + + meterGridOptions = { + rowSelection: { + mode: 'multiRow' as const, + checkboxes: true, + headerCheckbox: true, + }, + } + + onMeterGridReady(event: GridReadyEvent) { + this.meterGridApi = event.api + // Select all meters by default + this.meterGridApi.selectAll() + } + + onMeterSelectionChanged(_event: SelectionChangedEvent) { + this.applyMeterFilter() + } + + applyMeterFilter() { + const selectedMeters = (this.meterGridApi?.getSelectedRows() as GroupMeter[]) ?? [] + const timeColumns = ['start_time', 'end_time', 'month', 'year'] + const meterLabels = selectedMeters.map((m) => this._getMeterLabel(m)) + + const selectedColumns = new Set([...timeColumns, ...meterLabels]) + this.readingColumnDefs = this.allReadingColumnDefs.filter((col) => selectedColumns.has(col.field)) + this.readings = this.allReadings.filter((reading) => meterLabels.some((label) => label in reading)) + } + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this._groupsService.getMeters(this.orgId, this.groupId)), + tap((data) => { + this.meters = data + this.loadingMeters = false + this.loadReadings() + }), + ) + .subscribe() + } + + loadReadings() { + this.loadingReadings = true + this._groupsService.getMeterUsage(this.orgId, this.groupId, this.interval).subscribe({ + next: (data) => { + this.allReadings = data.readings + this.allReadingColumnDefs = data.column_defs.map((col) => ({ + headerName: col.headerName ?? col.field, + field: col.field, + flex: 1, + sortable: true, + filter: true, + })) + this.loadingReadings = false + this.applyMeterFilter() + }, + error: () => { + this.allReadings = [] + this.allReadingColumnDefs = [] + this.readings = [] + this.readingColumnDefs = [] + this.loadingReadings = false + }, + }) + } + + changeInterval(interval: MeterInterval) { + this.interval = interval + this.loadReadings() + } + + onMeterCellClicked(event: CellClickedEvent) { + const target = event.event?.target as HTMLElement + const action = target?.closest('[data-action]')?.getAttribute('data-action') + if (!action) return + + const meter = event.data as GroupMeter + switch (action) { + case 'edit': + this.editMeter(meter) + break + case 'delete': + this.deleteMeter(meter) + break + case 'add-readings': + this.addReadings(meter) + break + case 'navigate-property': + if (meter.view_id) { + void this._router.navigate(['/', this.inventoryType, meter.view_id, 'meters']) + } + break + case 'navigate-connection': + if (meter.service_group) { + void this._router.navigate(['/', this.inventoryType, 'groups', meter.service_group, 'systems']) + } + break + case 'view-readings': + this.viewMeterReadings(meter) + break + } + } + + createMeter() { + const data: CreateMeterDialogData = { orgId: this.orgId, groupId: this.groupId } + this._dialog + .open(CreateMeterDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) + .subscribe() + } + + editMeter(meter: GroupMeter) { + const data: EditMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } + this._dialog + .open(EditMeterDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) + .subscribe() + } + + deleteMeter(meter: GroupMeter) { + const data: DeleteMeterDialogData = { orgId: this.orgId, groupId: this.groupId, meter } + this._dialog + .open(DeleteMeterDialogComponent, { data, width: '400px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) + .subscribe() + } + + addReadings(meter: GroupMeter) { + const data: UploadReadingsDialogData = { orgId: this.orgId, groupId: this.groupId, meter } + this._dialog + .open(UploadReadingsDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this._refreshMeters()), + ) + .subscribe() + } + + viewMeterReadings(meter: GroupMeter) { + const data: MeterReadingsDialogData = { orgId: this.orgId, meter } + this._dialog.open(MeterReadingsDialogComponent, { data, width: '700px' }) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + private _getInventoryType(): string { + return ( + [...this._route.pathFromRoot] + .reverse() + .map((route) => route.snapshot.paramMap.get('type')) + .find((type): type is string => !!type) ?? 'properties' + ) + } + + private _getMeterLabel(meter: GroupMeter): string { + const source = meter.source ?? 'None' + const sourceId = meter.source_id ?? 'None' + return `${meter.type} - ${source} - ${sourceId}` + } + + private _refreshMeters() { + return this._groupsService.getMeters(this.orgId, this.groupId).pipe( + tap((data) => { + this.meters = data + this.loadReadings() + }), + ) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts new file mode 100644 index 00000000..61ae8d86 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/properties/properties.component.ts @@ -0,0 +1,13 @@ +import { Component, inject } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { InventoryComponent } from '../../../list/inventory.component' + +@Component({ + selector: 'seed-group-properties', + template: '', + imports: [InventoryComponent], + host: { class: 'flex flex-col flex-auto min-h-0' }, +}) +export class GroupPropertiesComponent { + groupId = Number.parseInt(inject(ActivatedRoute).parent.snapshot.paramMap.get('groupId')) +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts b/src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts new file mode 100644 index 00000000..9e195764 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/dialog-types.ts @@ -0,0 +1,17 @@ +import type { GroupSystem } from '@seed/api' + +export type SystemDialogData = { + action: 'create' | 'edit' | 'delete'; + orgId: number; + groupId: number; + system?: GroupSystem; +} + +export type ServiceDialogData = { + action: 'create' | 'edit' | 'delete'; + orgId: number; + groupId: number; + systemId: number; + systemName: string; + service?: { id: number; name: string; emission_factor: number | null }; +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html new file mode 100644 index 00000000..0481ed28 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.html @@ -0,0 +1,68 @@ +
+ +
Add Properties
+
+ + + + + + Type + + @for (t of meterTypes; track t) { + {{ t }} + } + + Select the meter type from the list + + + + + Flow Direction + + @for (opt of directionOptions; track opt.value) { + {{ opt.display }} + } + + + + +
+
Properties
+ + + @for (prop of selectedProperties; track prop.property_id) { +
+ {{ prop.property_display_name || 'Property ' + prop.property_id }} + +
+ } + + + + Select a property... + + @for (prop of availableProperties; track prop.property_id) { + + {{ prop.property_display_name || 'Property ' + prop.property_id }} + + } + + +
+ + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ + + + + + + diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts new file mode 100644 index 00000000..cbd5d961 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/dialogs/add-properties-dialog.component.ts @@ -0,0 +1,140 @@ +import { Component, inject, type OnInit } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { tap } from 'rxjs' +import type { GroupProperty } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' + +export type AddPropertiesDialogData = { + orgId: number; + groupId: number; + systemId: number; + serviceId: number; +} + +@Component({ + selector: 'seed-add-properties-dialog', + templateUrl: './add-properties-dialog.component.html', + imports: [MaterialImports, ReactiveFormsModule], +}) +export class AddPropertiesDialogComponent implements OnInit { + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + data = inject(MAT_DIALOG_DATA) + + properties: GroupProperty[] = [] + selectedProperties: GroupProperty[] = [] + availableProperties: GroupProperty[] = [] + errorMessage: string | null = null + submitted = false + + meterTypes = [ + 'Coal (anthracite)', + 'Coal (bituminous)', + 'Coke', + 'Diesel', + 'District Chilled Water', + 'District Chilled Water - Absorption', + 'District Chilled Water - Electric', + 'District Chilled Water - Engine', + 'District Chilled Water - Other', + 'District Hot Water', + 'District Steam', + 'Electric', + 'Electric - Grid', + 'Electric - Solar', + 'Electric - Wind', + 'Fuel Oil (No. 1)', + 'Fuel Oil (No. 2)', + 'Fuel Oil (No. 4)', + 'Fuel Oil (No. 5 and No. 6)', + 'Kerosene', + 'Natural Gas', + 'Other', + 'Propane', + 'Wood', + 'Cost', + 'Electric - Unknown', + 'Custom Meter', + 'Potable Indoor', + 'Potable Outdoor', + 'Potable: Mixed Indoor/Outdoor', + ] + + directionOptions = [ + { display: 'Imported', value: 'imported' }, + { display: 'Exported', value: 'exported' }, + ] + + form = new FormGroup({ + type: new FormControl(null, Validators.required), + direction: new FormControl('imported', Validators.required), + }) + + ngOnInit() { + this._groupsService + .getProperties(this.data.orgId, this.data.groupId) + .pipe( + tap((properties) => { + this.properties = properties + this.availableProperties = [...properties] + }), + ) + .subscribe() + } + + selectProperty(propertyId: number) { + if (!propertyId) return + const prop = this.properties.find((p) => p.property_id === propertyId) + if (prop && !this.selectedProperties.includes(prop)) { + this.selectedProperties.push(prop) + this.availableProperties = this.availableProperties.filter((p) => p.property_id !== propertyId) + } + } + + removeProperty(prop: GroupProperty) { + this.selectedProperties = this.selectedProperties.filter((p) => p !== prop) + this.availableProperties.push(prop) + } + + onSubmit() { + if (this.form.invalid || this.selectedProperties.length === 0) return + this.submitted = true + this.errorMessage = null + + const formValue = this.form.value as { type: string | null; direction: string | null } + const payload = { + type: formValue.type ?? '', + direction: formValue.direction ?? '', + property_ids: this.selectedProperties.map((p) => p.property_id), + } + + this._groupsService + .createServiceMeters(this.data.orgId, this.data.groupId, this.data.systemId, this.data.serviceId, payload) + .subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: (err: unknown) => { + this.submitted = false + this.errorMessage = 'Failed to create meters' + if (err !== null && typeof err === 'object') { + const error = err as Record + if (error.error && typeof error.error === 'object') { + const errorDetail = error.error as Record + if (typeof errorDetail.errors === 'string') { + this.errorMessage = errorDetail.errors + } else if (typeof errorDetail.message === 'string') { + this.errorMessage = errorDetail.message + } + } + } + }, + }) + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html new file mode 100644 index 00000000..1e1a733b --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.html @@ -0,0 +1,69 @@ + + + +
+ +
+ +
+ + @if (loading) { +
Loading...
+ } @else if (!service) { +
Service not found
+ } @else { + +
+ +
+ + +
+
Connected Properties
+ + @if (service.properties.length) { + + + + + + + + + + + @for (prop of service.properties; track prop.property_id) { + + + + + + + } + +
PropertyConnected ViaConnection TypeHas Meter Data
+ + {{ prop.property_display_name || 'Property ' + prop.property_id }} + + + + {{ prop.meter_alias || 'Meter ' + prop.meter_id }} + + {{ prop.meter_type }}{{ prop.has_meter_data ? 'Yes' : 'No' }}
+ } @else { +
No properties connected to this service
+ } +
+ } +
diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts new file mode 100644 index 00000000..6897db40 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-detail/service-detail.component.ts @@ -0,0 +1,104 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { ActivatedRoute, Router, RouterLink } from '@angular/router' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupServiceDetail } from '@seed/api' +import { GroupsService, 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 { AddPropertiesDialogData } from './dialogs/add-properties-dialog.component' +import { AddPropertiesDialogComponent } from './dialogs/add-properties-dialog.component' + +@Component({ + selector: 'seed-service-detail', + templateUrl: './service-detail.component.html', + imports: [MaterialImports, PageComponent, RouterLink], +}) +export class ServiceDetailComponent implements OnDestroy, OnInit { + private _dialog = inject(MatDialog) + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + + groupId: number + systemId: number + serviceId: number + orgId: number + inventoryType: InventoryType + service: GroupServiceDetail | null = null + loading = true + + ngOnInit() { + this.systemId = parseInt(this._route.snapshot.paramMap.get('systemId')) + this.serviceId = parseInt(this._route.snapshot.paramMap.get('serviceId')) + + // Walk up to find groupId and type from parent routes using pathFromRoot + for (const route of this._route.pathFromRoot) { + if (!this.groupId) { + const gid = route.snapshot.paramMap.get('groupId') + if (gid) { + this.groupId = parseInt(gid) + } + } + if (!this.inventoryType) { + const type = route.snapshot.paramMap.get('type') + if (type) { + this.inventoryType = type as InventoryType + } + } + } + + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this.loadService()), + ) + .subscribe() + } + + loadService() { + this.loading = true + return this._groupsService.getServiceDetail(this.orgId, this.groupId, this.systemId, this.serviceId).pipe( + tap((data) => { + this.service = data + this.loading = false + }), + ) + } + + addProperties() { + const data: AddPropertiesDialogData = { + orgId: this.orgId, + groupId: this.groupId, + systemId: this.systemId, + serviceId: this.serviceId, + } + this._dialog + .open(AddPropertiesDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadService()), + ) + .subscribe() + } + + goBackToSystems() { + // Build absolute path: /:inventoryType/groups/:groupId/systems + void this._router.navigate(['/', this.inventoryType, 'groups', this.groupId, 'systems']) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html new file mode 100644 index 00000000..7f259e67 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.html @@ -0,0 +1,45 @@ +
+
+
{{ title }}
+ + @if (action === 'delete') { +

+ Are you sure you want to delete the service {{ serviceName }}? +

+ } @else { +
+ + Service Name + + + + + Emission Factor + + +
+ } +
+ +
+ + @if (action === 'delete') { + + } @else { + + } +
+
diff --git a/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts new file mode 100644 index 00000000..399fe3e5 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/service-dialog/service-dialog.component.ts @@ -0,0 +1,73 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import type { ServiceDialogData } from '../dialog-types' + +@Component({ + selector: 'seed-service-dialog', + templateUrl: './service-dialog.component.html', + imports: [FormsModule, MaterialImports], +}) +export class ServiceDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as ServiceDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + action = this._data.action + systemName = this._data.systemName + serviceName = this._data.service?.name ?? '' + emissionFactor: number | null = this._data.service?.emission_factor ?? null + submitted = false + + get title(): string { + if (this.action === 'create') return `Create Service for ${this.systemName}` + if (this.action === 'edit') return `Edit Service for ${this.systemName}` + return `Delete Service for ${this.systemName}` + } + + get isValid(): boolean { + return this.serviceName.trim().length > 0 + } + + save() { + if (this.submitted) return + this.submitted = true + + const payload = { + name: this.serviceName.trim(), + emission_factor: this.emissionFactor, + system_id: this._data.systemId, + } + + const serviceId = this._data.service?.id + const obs + = this.action === 'create' + ? this._groupsService.createService(this._data.orgId, this._data.groupId, this._data.systemId, payload) + : this._groupsService.updateService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId, payload) + + obs.subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) + } + + deleteService() { + if (this.submitted) return + this.submitted = true + const serviceId = this._data.service?.id + this._groupsService.deleteService(this._data.orgId, this._data.groupId, this._data.systemId, serviceId).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html new file mode 100644 index 00000000..6473ec82 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.html @@ -0,0 +1,134 @@ +
+
+
{{ title }}
+ + @if (action === 'delete') { +

+ Are you sure you want to delete the system {{ systemName }}? +

+ } @else { +
+ + + System Name + + + + + @if (!isEdit) { + + System Type + + @for (t of systemTypes; track t) { + {{ t }} + } + + + } + + + @if (systemType === 'DES') { + + DES Type + + @for (t of desTypes; track t) { + {{ t }} + } + + + + @if (desType !== 'Chiller') { + + Heating Capacity (MMBtu) + + + } + + @if (desType === 'Chiller' || desType === 'CHP') { + + Cooling Capacity (Ton) + + + } + + + Count + + + } + + + @if (systemType === 'EVSE') { + + EVSE Type + + @for (t of evseTypes; track t) { + {{ t }} + } + + + + + Power (kW) + + + + + Voltage (V) + + + + + Count + + + } + + + @if (systemType === 'Battery') { + + Efficiency (%) + + + + + Power Capacity (kW) + + + + + Energy Capacity (kWh) + + + + + Voltage (V) + + + } +
+ } +
+ +
+ + @if (action === 'delete') { + + } @else { + + } +
+
diff --git a/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts new file mode 100644 index 00000000..cf3fe4ca --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/system-dialog/system-dialog.component.ts @@ -0,0 +1,109 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import type { DesType, EvseType, GroupSystem, SystemType } from '@seed/api' +import { GroupsService } from '@seed/api' +import { MaterialImports } from '@seed/materials' +import type { SystemDialogData } from '../dialog-types' + +@Component({ + selector: 'seed-system-dialog', + templateUrl: './system-dialog.component.html', + imports: [FormsModule, MaterialImports], +}) +export class SystemDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as SystemDialogData + private _dialogRef = inject(MatDialogRef) + private _groupsService = inject(GroupsService) + + action = this._data.action + systemName = this._data.system?.name ?? '' + systemType: SystemType = (this._data.system?.type as SystemType) ?? 'DES' + desType: DesType = (this._data.system?.des_type as DesType) ?? 'Boiler' + evseType: EvseType = (this._data.system?.evse_type as EvseType) ?? 'Level1-120V' + coolingCapacity: number | null = this._data.system?.cooling_capacity ?? null + heatingCapacity: number | null = this._data.system?.heating_capacity ?? null + count = this._data.system?.count ?? 1 + power: number | null = this._data.system?.power ?? null + voltage: number | null = this._data.system?.voltage ?? null + efficiency: number | null = this._data.system?.efficiency ?? null + powerCapacity: number | null = this._data.system?.power_capacity ?? null + energyCapacity: number | null = this._data.system?.energy_capacity ?? null + submitted = false + isEdit = this._data.action === 'edit' + + systemTypes: SystemType[] = ['DES', 'EVSE', 'Battery', 'Aggregate Meter'] + desTypes: DesType[] = ['Boiler', 'Chiller', 'CHP'] + evseTypes: EvseType[] = ['Level1-120V', 'Level2-240V', 'Level3-DC Fast'] + + get title(): string { + if (this.action === 'create') return 'Create System' + if (this.action === 'edit') return 'Edit System' + return 'Delete System' + } + + get isValid(): boolean { + if (!this.systemName.trim()) return false + if (this.systemType === 'EVSE') return this.power != null && this.voltage != null + if (this.systemType === 'Battery') { + return this.efficiency != null && this.powerCapacity != null && this.energyCapacity != null && this.voltage != null + } + return true + } + + save() { + if (this.submitted) return + this.submitted = true + + const payload: Partial = { name: this.systemName.trim() } + + // Backend needs `type` for both create and update to resolve the model class + payload.type = this.systemType + + if (this.systemType === 'DES') { + payload.des_type = this.desType + payload.count = this.count + payload.cooling_capacity = this.desType === 'Chiller' ? this.coolingCapacity : null + payload.heating_capacity = this.desType !== 'Chiller' ? this.heatingCapacity : null + } else if (this.systemType === 'EVSE') { + payload.evse_type = this.evseType + payload.power = this.power + payload.voltage = this.voltage + payload.count = this.count + } else if (this.systemType === 'Battery') { + payload.efficiency = this.efficiency + payload.power_capacity = this.powerCapacity + payload.energy_capacity = this.energyCapacity + payload.voltage = this.voltage + } + + const systemId = this._data.system?.id + const obs + = this.action === 'create' + ? this._groupsService.createSystem(this._data.orgId, this._data.groupId, payload) + : this._groupsService.updateSystem(this._data.orgId, this._data.groupId, systemId, payload) + + obs.subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) + } + + deleteSystem() { + if (this.submitted) return + this.submitted = true + const systemId = this._data.system?.id + this._groupsService.deleteSystem(this._data.orgId, this._data.groupId, systemId).subscribe({ + next: () => { + this._dialogRef.close(true) + }, + error: () => { + this.submitted = false + }, + }) + } +} diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.html b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html new file mode 100644 index 00000000..00a3537c --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.html @@ -0,0 +1,153 @@ + + + +
+ +
+ +
+ + @if (loading) { +
Loading...
+ } @else if (systemTypeKeys.length === 0) { +
No systems defined for this group
+ } @else { + @for (typeKey of systemTypeKeys; track typeKey) { +
+
{{ typeKey }}
+ + + + + + + + @if (getBaseType(typeKey) === 'DES') { + + + + + } + @if (getBaseType(typeKey) === 'EVSE') { + + + + + } + @if (getBaseType(typeKey) === 'Battery') { + + + + + } + @if (getBaseType(typeKey) === 'Aggregate Meter') { + + } + + + + + @for (system of systemsByType[typeKey]; track system.id) { + + + + + @if (getBaseType(typeKey) === 'DES') { + + + + + } + @if (getBaseType(typeKey) === 'EVSE') { + + + + + } + @if (getBaseType(typeKey) === 'Battery') { + + + + + } + @if (getBaseType(typeKey) === 'Aggregate Meter') { + + } + + + + @if (expandedSystems.has(system.id) && system.services?.length) { + + + + } + } + +
NameDES TypeHeating Capacity (MMBtu)Cooling Capacity (Ton)CountEVSE TypePower (kW)Voltage (V)CountEfficiency (%)Power Capacity (kW)Energy Capacity (kWh)Voltage (V)ModeActions
+ @if (system.services?.length) { + + } + {{ system.name }}{{ system.des_type || '—' }}{{ system.heating_capacity ?? '—' }}{{ system.cooling_capacity ?? '—' }}{{ system.count }}{{ system.evse_type || '—' }}{{ system.power ?? '—' }}{{ system.voltage ?? '—' }}{{ system.count }}{{ system.efficiency ?? '—' }}{{ system.power_capacity ?? '—' }}{{ system.energy_capacity ?? '—' }}{{ system.voltage ?? '—' }}{{ system.mode || '—' }} +
+ + + {{ system.services?.length ?? 0 }} {{ (system.services?.length ?? 0) === 1 ? 'service' : 'services' }} + + + + +
+
+ + + + + + + + + + @for (service of system.services; track service.id) { + + + + + + } + +
NameEmission FactorActions
{{ service.name }}{{ service.emission_factor ?? '—' }} + + + +
+
+
+ } + } +
diff --git a/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts b/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts new file mode 100644 index 00000000..3b6a2a98 --- /dev/null +++ b/src/app/modules/inventory-list/groups/detail/systems/systems.component.ts @@ -0,0 +1,183 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { MatTooltipModule } from '@angular/material/tooltip' +import { ActivatedRoute, Router } from '@angular/router' +import { filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { GroupService, GroupSystem } from '@seed/api' +import { GroupsService, OrganizationService } from '@seed/api' +import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import type { ServiceDialogData, SystemDialogData } from './dialog-types' +import { ServiceDialogComponent } from './service-dialog/service-dialog.component' +import { SystemDialogComponent } from './system-dialog/system-dialog.component' + +@Component({ + selector: 'seed-group-systems', + templateUrl: './systems.component.html', + imports: [MaterialImports, MatTooltipModule, PageComponent], +}) +export class GroupSystemsComponent implements OnDestroy, OnInit { + private _groupsService = inject(GroupsService) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _dialog = inject(MatDialog) + private _router = inject(Router) + private readonly _unsubscribeAll$ = new Subject() + + groupId = parseInt(this._route.parent.snapshot.paramMap.get('groupId')) + orgId: number + systemsByType: Record = {} + systemTypeKeys: string[] = [] + expandedSystems = new Set() + loading = true + + ngOnInit() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + filter((org) => Boolean(org?.org_id)), + take(1), + tap(({ org_id }) => { + this.orgId = org_id + }), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + loadSystems() { + this.loading = true + return this._groupsService.getSystemsByType(this.orgId, this.groupId).pipe( + tap((data) => { + this.systemsByType = data + this.systemTypeKeys = Object.keys(data) + this.loading = false + }), + ) + } + + createSystem() { + const data: SystemDialogData = { action: 'create', orgId: this.orgId, groupId: this.groupId } + this._dialog + .open(SystemDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + editSystem(system: GroupSystem) { + const data: SystemDialogData = { action: 'edit', orgId: this.orgId, groupId: this.groupId, system } + this._dialog + .open(SystemDialogComponent, { data, width: '500px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + deleteSystem(system: GroupSystem) { + const data: SystemDialogData = { action: 'delete', orgId: this.orgId, groupId: this.groupId, system } + this._dialog + .open(SystemDialogComponent, { data, width: '400px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + createService(system: GroupSystem) { + const data: ServiceDialogData = { + action: 'create', + orgId: this.orgId, + groupId: this.groupId, + systemId: system.id, + systemName: system.name, + } + this._dialog + .open(ServiceDialogComponent, { data, width: '450px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + editService(system: GroupSystem, service: GroupService) { + const data: ServiceDialogData = { + action: 'edit', + orgId: this.orgId, + groupId: this.groupId, + systemId: system.id, + systemName: system.name, + service, + } + this._dialog + .open(ServiceDialogComponent, { data, width: '450px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + deleteService(system: GroupSystem, service: GroupService) { + const data: ServiceDialogData = { + action: 'delete', + orgId: this.orgId, + groupId: this.groupId, + systemId: system.id, + systemName: system.name, + service, + } + this._dialog + .open(ServiceDialogComponent, { data, width: '400px' }) + .afterClosed() + .pipe( + filter(Boolean), + switchMap(() => this.loadSystems()), + ) + .subscribe() + } + + toggleServices(systemId: number) { + if (this.expandedSystems.has(systemId)) { + this.expandedSystems.delete(systemId) + } else { + this.expandedSystems.add(systemId) + } + } + + getColspan(typeKey: string): number { + const base = this.getBaseType(typeKey) + // +1 for the expand/collapse column + const colMap: Record = { DES: 7, EVSE: 7, Battery: 7, 'Aggregate Meter': 4 } + return colMap[base] ?? 4 + } + + getBaseType(typeKey: string): string { + if (typeKey.startsWith('DES')) return 'DES' + if (typeKey.startsWith('EVSE')) return 'EVSE' + if (typeKey.startsWith('Battery')) return 'Battery' + if (typeKey.startsWith('Aggregate Meter')) return 'Aggregate Meter' + return typeKey + } + + viewServiceDetail(system: GroupSystem, service: GroupService) { + void this._router.navigate(['services', system.id, service.id], { relativeTo: this._route }) + } + + ngOnDestroy() { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/inventory-list/groups/groups.component.ts b/src/app/modules/inventory-list/groups/groups.component.ts index 88375a9a..0449145f 100644 --- a/src/app/modules/inventory-list/groups/groups.component.ts +++ b/src/app/modules/inventory-list/groups/groups.component.ts @@ -3,7 +3,7 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject } from '@angular/core' import type { MatDialogRef } from '@angular/material/dialog' import { MatDialog } from '@angular/material/dialog' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { Observable } from 'rxjs' @@ -26,6 +26,7 @@ export class GroupsComponent implements OnDestroy, OnInit { private _groupsService = inject(GroupsService) private _organizationService = inject(OrganizationService) private _route = inject(ActivatedRoute) + private _router = inject(Router) private readonly _unsubscribeAll$ = new Subject() columnDefs: ColDef[] = [] @@ -78,22 +79,22 @@ export class GroupsComponent implements OnDestroy, OnInit { ] } - nameRenderer = ({ data, value }: { data: InventoryGroup; value: string }) => { - return `${value}` + nameRenderer = ({ value }: { data: InventoryGroup; value: string }) => { + return `${value}` } actionRenderer = () => { return ` -
- edit - clear +
+ edit + delete
` } setRowData() { this.rowData = [] - for (const group of this.groups) { + for (const group of this.groups ?? []) { const row = { id: group.id, name: group.name, @@ -115,13 +116,13 @@ export class GroupsComponent implements OnDestroy, OnInit { } onCellClicked(event: CellClickedEvent) { - if (event.colDef.field !== 'actions') return - const target = event.event.target as HTMLElement - const action = target.getAttribute('data-action') + const action = target.closest('[data-action]')?.getAttribute('data-action') const { id, name } = event.data as { id: number; name: string } - if (action === 'edit') { + if (action === 'navigate') { + void this._router.navigate([`/${this.type}/groups`, id]) + } else if (action === 'edit') { this.editGroup(id) } else if (action === 'delete') { this.openDeleteModal(id, name) diff --git a/src/app/modules/inventory-list/groups/modal/form-modal.component.ts b/src/app/modules/inventory-list/groups/modal/form-modal.component.ts index 8748b6c8..a34e116b 100644 --- a/src/app/modules/inventory-list/groups/modal/form-modal.component.ts +++ b/src/app/modules/inventory-list/groups/modal/form-modal.component.ts @@ -91,15 +91,25 @@ export class FormModalComponent implements OnDestroy, OnInit { access_level_instance: this.form.value.access_level_instance, } - this._groupsService.create(this.data.orgId, data as InventoryGroup).subscribe(({ id }) => { - this._dialogRef.close(id) + this._groupsService.create(this.data.orgId, data as InventoryGroup).subscribe({ + next: (group) => { + this._dialogRef.close(group?.id ?? true) + }, + error: () => { + this._dialogRef.close(false) + }, }) } onEdit() { const data = { ...this.data.group, name: this.form.value.name } - this._groupsService.update(this.data.orgId, this.data.id, data).subscribe(({ id }) => { - this._dialogRef.close(id) + this._groupsService.update(this.data.orgId, this.data.id, data).subscribe({ + next: (group) => { + this._dialogRef.close(group?.id ?? true) + }, + error: () => { + this._dialogRef.close(false) + }, }) } 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 95726eb2..ba3b1677 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.html +++ b/src/app/modules/inventory-list/list/grid/actions.component.html @@ -14,92 +14,108 @@ - - - + @if (!groupMode) { + + + + } - - - Access Levels -
+ @if (!groupMode) { + + + Access Levels +
+ @if (type === 'properties') { + + } + +
+ @if (type === 'properties') { - + + Analyses +
+ +
} - -
- - @if (type === 'properties') { + + @if (type === 'properties') { + + Audit Template +
+ + + + +
+ } + + + - Analyses + Data Quality
- +
- } - - @if (type === 'properties') { + - Audit Template + Derived Data
- - - - + +
+ + + + Groups +
+
} - - - - - Data Quality -
- -
- - - Derived Data -
- -
- - - - Groups -
- -
Labels
- + +
Other - @if (type === 'properties') { - + @if (!groupMode) { + @if (type === 'properties') { + + } + + + + + Salesforce +
+ + +
} - - - - - Salesforce -
- -
UBID
- + @if (!groupMode) { + + } - + @if (!groupMode) { + + }
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 f7d6f34c..6870f977 100644 --- a/src/app/modules/inventory-list/list/grid/actions.component.ts +++ b/src/app/modules/inventory-list/list/grid/actions.component.ts @@ -37,6 +37,7 @@ import { export class ActionsComponent implements OnDestroy, OnChanges, OnInit { @Input() cycleId: number @Input() gridApi: GridApi + @Input() groupMode = false @Input() inventory: Record[] @Input() orgId: number @Input() profile: Profile @@ -44,7 +45,7 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { @Input() selectedStateIds: number[] @Input() selectedViewIds: number[] @Input() type: InventoryType - @Output() refreshInventory = new EventEmitter() + @Output() refreshInventory = new EventEmitter() @Output() selectedAll = new EventEmitter() private _inventoryService = inject(InventoryService) private _dialog = inject(MatDialog) @@ -267,8 +268,8 @@ export class ActionsComponent implements OnDestroy, OnChanges, OnInit { .afterClosed() .pipe( filter(Boolean), - tap(() => { - this.refreshInventory.emit() + tap((result) => { + this.refreshInventory.emit(typeof result === 'number' ? result : null) }), ) .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 8ec86538..955e8bcc 100644 --- a/src/app/modules/inventory-list/list/grid/grid.component.ts +++ b/src/app/modules/inventory-list/list/grid/grid.component.ts @@ -10,6 +10,7 @@ import { ConfigService } from '@seed/services' import type { FiltersSorts, InventoryType, Pagination } from '../../../inventory' import { CellHeaderMenuComponent } from './cell-header-menu.component' import { InventoryGridControlsComponent } from './grid-controls.component' +import { IconHeaderComponent } from './icon-header.component' @Component({ selector: 'seed-inventory-grid', @@ -115,10 +116,10 @@ export class InventoryGridComponent implements OnChanges { getShortcutColumns(): ColDef[] { const shortcutColumns = [ this.buildInfoCell(), - 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'), + this.buildShortcutColumn('merged_indicator', 'Merged', 44, 'merge'), + this.buildShortcutColumn('meters_exist_indicator', 'Meters', 44, 'bolt', 'meters'), + this.buildShortcutColumn('notes_count', 'Notes', 44, 'mode_comment', 'notes'), + this.buildShortcutColumn('groups_indicator', 'Groups', 44, 'workspaces'), this.buildLabelsCell(), ] return shortcutColumns @@ -140,11 +141,14 @@ export class InventoryGridComponent implements OnChanges { return { field, headerName, + headerTooltip: headerName, + headerComponent: IconHeaderComponent, + headerComponentParams: { icon, tooltip: headerName }, + width: maxWidth, maxWidth, filter: false, sortable: false, suppressMovable: true, - headerClass: 'white-space-normal', cellClass: 'overflow-hidden', cellRenderer: ({ value }) => this.actionRenderer(value, icon, action), } @@ -155,21 +159,20 @@ export class InventoryGridComponent implements OnChanges { return { field, headerName: 'Info', + headerTooltip: 'Info', + headerComponent: IconHeaderComponent, + headerComponentParams: { icon: 'info', tooltip: 'Info' }, filter: false, sortable: false, - resizable: false, suppressMovable: true, - maxWidth: 60, + width: 44, + maxWidth: 44, cellRenderer: ({ value }) => this.actionRenderer(value, 'info', 'detail'), } } actionRenderer = (value, icon: string, action: string) => { if (!value) return '' - // Allow a single letter to be passed as an indicator (like G for groups) - if (icon.length === 1) { - return `${icon}` - } const cursorClass = action ? 'cursor-pointer' : '' return ` diff --git a/src/app/modules/inventory-list/list/grid/icon-header.component.ts b/src/app/modules/inventory-list/list/grid/icon-header.component.ts new file mode 100644 index 00000000..7db17bef --- /dev/null +++ b/src/app/modules/inventory-list/list/grid/icon-header.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core' +import type { IHeaderAngularComp } from 'ag-grid-angular' +import type { IHeaderParams } from 'ag-grid-community' + +type IconHeaderParams = IHeaderParams & { + icon: string; + tooltip: string; +} + +@Component({ + selector: 'seed-icon-header', + template: ' {{ icon }} ', + styles: ` + :host { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + } + `, +}) +export class IconHeaderComponent implements IHeaderAngularComp { + icon = '' + tooltip = '' + + agInit(params: IconHeaderParams): void { + this.icon = params.icon ?? '' + this.tooltip = params.tooltip ?? params.displayName ?? '' + } + + refresh(): boolean { + return false + } +} diff --git a/src/app/modules/inventory-list/list/grid/index.ts b/src/app/modules/inventory-list/list/grid/index.ts index 1f1dce8b..82f07fed 100644 --- a/src/app/modules/inventory-list/list/grid/index.ts +++ b/src/app/modules/inventory-list/list/grid/index.ts @@ -5,3 +5,4 @@ export * from './filter-sort-chips.component' export * from './grid-controls.component' export * from './grid.component' export * from './config-selector.component' +export * from './icon-header.component' diff --git a/src/app/modules/inventory-list/list/inventory.component.html b/src/app/modules/inventory-list/list/inventory.component.html index 474a1fc4..c53816b4 100644 --- a/src/app/modules/inventory-list/list/inventory.component.html +++ b/src/app/modules/inventory-list/list/inventory.component.html @@ -1,11 +1,13 @@ - - +@if (!groupId) { + + +}
@@ -16,6 +18,7 @@ diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index deadd3a0..1f8bee39 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -1,11 +1,11 @@ import type { OnDestroy, OnInit } from '@angular/core' -import { Component, inject, ViewEncapsulation } from '@angular/core' +import { Component, inject, Input, ViewEncapsulation } from '@angular/core' import { ActivatedRoute } from '@angular/router' import type { ColDef, GridApi } from 'ag-grid-community' import type { Observable } from 'rxjs' import { BehaviorSubject, catchError, combineLatest, filter, map, of, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { Column, CurrentUser, Cycle, Label, OrganizationUserResponse, OrganizationUserSettings } from '@seed/api' -import { ColumnService, CycleService, InventoryService, LabelService, OrganizationService, UserService } from '@seed/api' +import { ColumnService, CycleService, GroupsService, InventoryService, LabelService, OrganizationService, UserService } from '@seed/api' import { PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { naturalSort } from '@seed/utils' @@ -31,6 +31,7 @@ import type { LabelSelections } from './grid/filter-group/filter-group-selector. selector: 'seed-inventory', templateUrl: './inventory.component.html', encapsulation: ViewEncapsulation.None, + host: { class: 'flex flex-col flex-auto min-h-0' }, imports: [ ActionsComponent, ConfigSelectorComponent, @@ -45,13 +46,16 @@ export class InventoryComponent implements OnDestroy, OnInit { private _activatedRoute = inject(ActivatedRoute) private _columnService = inject(ColumnService) private _cycleService = inject(CycleService) + private _groupsService = inject(GroupsService) private _inventoryService = inject(InventoryService) private _organizationService = inject(OrganizationService) private _labelService = inject(LabelService) private _userService = inject(UserService) private readonly _unsubscribeAll$ = new Subject() readonly tabs: InventoryType[] = ['properties', 'taxlots'] - readonly type = this._activatedRoute.snapshot.paramMap.get('type') as InventoryType + readonly type = this._resolveType() + @Input() groupId?: number + private _groupPropertyIds: number[] | null = null chunk = 100 columns: Column[] = [] columnDefs: ColDef[] = [] @@ -75,7 +79,7 @@ export class InventoryComponent implements OnDestroy, OnInit { profileId: number profileId$ = new BehaviorSubject(null) propertyProfiles: Profile[] - refreshInventory$ = new Subject() + refreshInventory$ = new Subject() rowData: Record[] selectedViewIds: number[] = [] selectedStateIds: number[] = [] @@ -134,13 +138,41 @@ export class InventoryComponent implements OnDestroy, OnInit { ) .subscribe() - this._organizationService.orgUserSettings$.pipe(tap((settings) => (this.userSettings = settings))).subscribe() + this._organizationService.orgUserSettings$ + .pipe( + takeUntil(this._unsubscribeAll$), + tap((settings) => (this.userSettings = settings)), + ) + .subscribe() + + this.refreshInventory$ + .pipe( + takeUntil(this._unsubscribeAll$), + switchMap((profileId) => this.refreshInventory(profileId)), + ) + .subscribe() + } - this.refreshInventory$.pipe(switchMap(() => this.refreshInventory())).subscribe() + onRefreshInventory(profileId: number | null) { + this.refreshInventory$.next(profileId) } - refreshInventory() { - return this.updateOrgUserSettings().pipe(switchMap(() => this.loadInventory())) + refreshInventory(newProfileId?: number | null) { + return this._inventoryService.getColumnListProfiles('List View Profile', 'properties', true).pipe( + tap((profiles) => { + this.propertyProfiles = profiles.filter((p) => p.inventory_type === 0) + this.taxlotProfiles = profiles.filter((p) => p.inventory_type === 1) + }), + switchMap(() => { + const targetId = newProfileId ?? this.profileId + if (targetId) { + return this.getProfile(targetId) + } + return of(null) + }), + switchMap(() => this.updateOrgUserSettings()), + switchMap(() => this.loadInventory()), + ) } /* @@ -227,40 +259,52 @@ export class InventoryComponent implements OnDestroy, OnInit { return of(null) } - const inventory_type = this.type === 'properties' ? 'property' : 'taxlot' - const params = new URLSearchParams({ - cycle: this.cycleId.toString(), - ids_only: 'false', - include_related: 'true', - organization_id: this.orgId.toString(), - page: this.page.toString(), - per_page: this.chunk.toString(), - inventory_type, - }) - - const { includeViewIds, excludeViewIds } = this._computeLabelViewIds() - const data: Record = { - include_property_ids: null, - profile_id: this.profileId, - filters: this.filters, - sorts: this.sorts, - } - if (includeViewIds) { - data.include_view_ids = includeViewIds - } - if (excludeViewIds) { - data.exclude_view_ids = excludeViewIds - } - - return this._inventoryService.getAgInventory(params.toString(), data).pipe( - tap(({ pagination, results, column_defs }: AgFilterResponse) => { - this.pagination = pagination - this.inventory = results + // If in group mode, fetch group property IDs first + const groupIds$ = this.groupId + ? this._groupsService.getProperties(this.orgId, this.groupId).pipe( + tap((props) => { + this._groupPropertyIds = props.map((p) => p.property_id) + }), + ) + : of(null) + + return groupIds$.pipe( + switchMap(() => { + const inventory_type = this.type === 'properties' ? 'property' : 'taxlot' + const params = new URLSearchParams({ + cycle: this.cycleId.toString(), + ids_only: 'false', + include_related: 'true', + organization_id: this.orgId.toString(), + page: this.page.toString(), + per_page: this.chunk.toString(), + inventory_type, + }) + + const { includeViewIds, excludeViewIds } = this._computeLabelViewIds() + const data: Record = { + include_property_ids: this._groupPropertyIds, + profile_id: this.profileId, + filters: this.filters, + sorts: this.sorts, + } + if (includeViewIds) { + data.include_view_ids = includeViewIds + } + if (excludeViewIds) { + data.exclude_view_ids = excludeViewIds + } - this.columnDefs = column_defs - this.rowData = results + return this._inventoryService.getAgInventory(params.toString(), data).pipe( + tap(({ pagination, results, column_defs }: AgFilterResponse) => { + this.pagination = pagination + this.inventory = results + this.columnDefs = column_defs + this.rowData = results + }), + map(() => null), + ) }), - map(() => null), ) as Observable } @@ -326,7 +370,7 @@ export class InventoryComponent implements OnDestroy, OnInit { onPageChange(page: number) { this.page = page - this.refreshInventory$.next() + this.refreshInventory$.next(null) } setFilters() { @@ -418,7 +462,7 @@ export class InventoryComponent implements OnDestroy, OnInit { this.page = 1 this.userSettings.filters[this.type] = filters this.userSettings.sorts[this.type] = sorts - this.refreshInventory$.next() + this.refreshInventory$.next(null) } ngOnDestroy(): void { @@ -502,4 +546,16 @@ export class InventoryComponent implements OnDestroy, OnInit { ) .subscribe() } + + private _resolveType(): InventoryType { + // Check current route first, then walk up route tree (for group detail context) + return ( + (this._activatedRoute.snapshot.paramMap.get('type') as InventoryType) + ?? ([...this._activatedRoute.pathFromRoot] + .reverse() + .map((route) => route.snapshot.paramMap.get('type')) + .find((type): type is string => !!type) as InventoryType) + ?? 'properties' + ) + } } diff --git a/src/app/modules/inventory-list/map/map.component.html b/src/app/modules/inventory-list/map/map.component.html index ef53bc85..73b58edf 100644 --- a/src/app/modules/inventory-list/map/map.component.html +++ b/src/app/modules/inventory-list/map/map.component.html @@ -1,12 +1,19 @@ - - -
+@if (!groupId) { + + + +} @else { + +} + + +
@@ -70,7 +77,7 @@ Tax Lot Ubid Centroids } @else { - + Hexagonal Bins @@ -91,6 +98,9 @@ > Property Ubid Centroids + + Footprints + } } -
- +
+
@@ -129,4 +158,4 @@
}
- + diff --git a/src/app/modules/inventory-list/map/map.component.ts b/src/app/modules/inventory-list/map/map.component.ts index 38c932c6..7cc1c731 100644 --- a/src/app/modules/inventory-list/map/map.component.ts +++ b/src/app/modules/inventory-list/map/map.component.ts @@ -1,7 +1,8 @@ +import { NgTemplateOutlet } from '@angular/common' import type { OnDestroy, OnInit } from '@angular/core' -import { Component, inject } from '@angular/core' +import { Component, inject, Input } from '@angular/core' import type { ProgressBarMode } from '@angular/material/progress-bar' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { type Feature, Overlay } from 'ol' import { defaults as defaultControls } from 'ol/control' import { buffer } from 'ol/extent' @@ -22,9 +23,9 @@ import Style from 'ol/style/Style' import Text from 'ol/style/Text' import View from 'ol/View' import HexBin from 'ol-ext/source/HexBin' -import { filter, finalize, last, map, mergeMap, range, scan, Subject, switchMap, takeUntil, tap } from 'rxjs' +import { filter, finalize, last, map, mergeMap, of, range, scan, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { CurrentUser, Label, LabelOperator, OrgCycle } from '@seed/api' -import { InventoryService, LabelService, OrganizationService, UserService } from '@seed/api' +import { ColumnService, GroupsService, InventoryService, LabelService, OrganizationService, UserService } from '@seed/api' import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { MapService } from '@seed/services/map' @@ -36,16 +37,25 @@ type Layer = VectorLayer | TileLayer @Component({ selector: 'seed-inventory-list-map', templateUrl: './map.component.html', - imports: [LabelsComponent, MaterialImports, NotFoundComponent, PageComponent, ProgressBarComponent], + imports: [NgTemplateOutlet, LabelsComponent, MaterialImports, NotFoundComponent, PageComponent, ProgressBarComponent], + host: { class: 'flex flex-col flex-auto min-h-0' }, }) export class MapComponent implements OnDestroy, OnInit { + private _columnService = inject(ColumnService) + private _groupsService = inject(GroupsService) private _inventoryService = inject(InventoryService) private _labelService = inject(LabelService) private _mapService = inject(MapService) private _organizationService = inject(OrganizationService) + private _router = inject(Router) private _userService = inject(UserService) private _route = inject(ActivatedRoute) private readonly _unsubscribeAll$ = new Subject() + private _groupPropertyIds: number[] | null = null + private _footprintColumnName: string | null = null + private _allColumnIds: number[] = [] + + @Input() groupId?: number baseLayer: TileLayer censusTractLayer: VectorLayer @@ -56,12 +66,14 @@ export class MapComponent implements OnDestroy, OnInit { cycle$ = new Subject() data: State[] = [] defaultField: 'property_display_field' | 'taxlot_display_field' + displayFieldColumnName: string + displayFieldIsExtraData = false + displayFieldKey: string filteredRecords = 0 + footprintLayer: VectorLayer geocodedData: State[] = [] geocodedProperties: State[] geocodedTaxlots: State[] - group: { views_list: number[] } // FUTURE: HANDLE GROUPS - groupId: number // FUTURE: HANDLE GROUPS hexBinColor = [75, 0, 130] hexBinLayer: VectorLayer hexBinMaxOpacity = 0.8 @@ -74,14 +86,8 @@ export class MapComponent implements OnDestroy, OnInit { map: Map orgId: number pointsLayer: VectorLayer - popupOverlay = new Overlay({ - element: document.getElementById('popup-element'), - positioning: 'bottom-center', - stopEvent: false, - autoPan: true, - // autoPanMargin: 75, ?????????? dne? - offset: [0, -10], - }) + popupInfo: { displayValue: string; viewId: number } | null = null + popupOverlay: Overlay progress = { current: 0, total: 0, percent: 0, chunk: 0 } propertyBBLayer: VectorLayer propertyCentroidLayer: VectorLayer @@ -94,11 +100,15 @@ export class MapComponent implements OnDestroy, OnInit { type = this._route.snapshot.paramMap.get('type') as InventoryTypeGoal ngOnInit(): void { + if (this.groupId) { + this.type = 'properties' + } this.defaultField = this.type === 'properties' ? 'property_display_field' : 'taxlot_display_field' this.getDependencies() .pipe( takeUntil(this._unsubscribeAll$), filter(() => !!this.cycle), + switchMap(() => this._fetchGroupPropertyIds()), switchMap(() => this.initMap()), tap(() => { this.initStreams() @@ -117,6 +127,25 @@ export class MapComponent implements OnDestroy, OnInit { this.orgId = org.id this.cycles = org.cycles this.cycle = this.cycles.find((c) => c.cycle_id === this.currentUser.settings.cycleId) ?? this.cycles[0] + this.displayFieldKey = this.type === 'taxlots' ? org.taxlot_display_field : org.property_display_field + }), + switchMap(() => (this.type === 'taxlots' ? this._columnService.taxLotColumns$ : this._columnService.propertyColumns$).pipe(take(1))), + tap((columns) => { + // Resolve the org display field (e.g., "Nick Name") to the column metadata + const displayFieldCol = columns.find((c) => c.display_name === this.displayFieldKey || c.column_name === this.displayFieldKey) + this.displayFieldKey = displayFieldCol?.name ?? this.displayFieldKey + this.displayFieldColumnName = displayFieldCol?.column_name ?? this.displayFieldKey + this.displayFieldIsExtraData = displayFieldCol?.is_extra_data ?? false + + // Store all column IDs so we can request extra data columns via shown_column_ids + this._allColumnIds = columns.map((c) => c.id) + + if (this.type === 'properties') { + const footprintCol = columns.find((c) => c.column_name === 'property_footprint') + this._footprintColumnName = footprintCol?.name ?? null + } else { + this._footprintColumnName = null + } }), switchMap(() => this.getLabels()), ) @@ -194,7 +223,7 @@ export class MapComponent implements OnDestroy, OnInit { fetchRecords(totalPages: number) { const inventory_type = this.type === 'properties' ? 'property' : 'taxlot' - const include_property_ids = this.type === 'goal' ? this.group?.views_list : [] + const include_property_ids = this._groupPropertyIds ?? [] this.requestParams = new URLSearchParams({ cycle: this.cycle.cycle_id.toString(), @@ -206,9 +235,15 @@ export class MapComponent implements OnDestroy, OnInit { inventory_type, }) + // Only include shown_column_ids when extra data fields are needed (display field or footprints). + // Without it, profile_id=null returns all standard fields which is sufficient for most cases. + // When extra data IS needed, we must pass all column IDs since shown_column_ids restricts standard fields too. + if (this.displayFieldIsExtraData && this._allColumnIds.length) { + this.requestParams.set('shown_column_ids', this._allColumnIds.join(',')) + } + this.requestData = { include_property_ids, - profile_id: null, filters: null, sorts: null, } @@ -247,6 +282,7 @@ export class MapComponent implements OnDestroy, OnInit { taxlotBBLayer: { zIndex: 5, visible: this.type !== 'properties' }, taxlotCentroidLayer: { zIndex: 6, visible: this.type !== 'properties' }, pointsLayer: { zIndex: 7, visible: true }, + footprintLayer: { zIndex: 8, visible: !!this._footprintColumnName }, } this.baseLayer = new TileLayer({ source: new OSM(), zIndex: this.layers.baseLayer.zIndex }) @@ -285,6 +321,12 @@ export class MapComponent implements OnDestroy, OnInit { style: this.taxlotStyle(), }) + this.footprintLayer = new VectorLayer({ + source: this.footprintSource(this.data), + zIndex: this.layers.footprintLayer.zIndex, + style: this.footprintStyle(), + }) + this.censusTractLayer = new VectorLayer({ source: await this.censusTractSource(), zIndex: this.layers.censusTractLayer.zIndex, @@ -324,25 +366,42 @@ export class MapComponent implements OnDestroy, OnInit { this.map.on('moveend', async () => { this.censusTractLayer.setSource(await this.censusTractSource()) }) - this.map.addOverlay(this.popupOverlay) + + const popupElement = document.getElementById('popup-element') + if (popupElement) { + this.popupOverlay = new Overlay({ + element: popupElement, + positioning: 'bottom-center', + stopEvent: true, + autoPan: true, + offset: [0, -10], + }) + this.map.addOverlay(this.popupOverlay) + } + this.map.on('click', (event) => { - const element = this.popupOverlay.getElement() const points: Feature[] = [] this.map.forEachFeatureAtPixel(event.pixel, (feature, layer) => { - // disregard hexBin/census clicks - if ( - ![this.layers.hexBinLayer.zIndex, this.layers.censusTractLayer.zIndex, undefined].includes(layer.getProperties().zIndex as number) - ) { + // disregard hexBin/census/footprint clicks + const ignoredZIndexes = [ + this.layers.hexBinLayer.zIndex, + this.layers.censusTractLayer.zIndex, + this.layers.footprintLayer.zIndex, + undefined, + ] + const layerZIndex = layer?.getProperties()?.zIndex as number | undefined + if (!ignoredZIndexes.includes(layerZIndex)) { points.push(...((feature.get('features') as Feature[]) ?? [])) } }) if (!points.length) { - console.log('jquery needs migrating: "$(element).popover("destroy")"') + this.dismissPopup() } else if (points.length === 1) { - this.showPointInfo(points[0], element) + this.showPointInfo(points[0]) } else { + this.dismissPopup() this.zoomOnCluster(points) } }) @@ -510,6 +569,30 @@ export class MapComponent implements OnDestroy, OnInit { pointsSource = (records = this.geocodedData) => new Cluster({ source: this.buildingSources(records), distance: 45 }) propertyStyle = () => new Style({ stroke: new Stroke({ color: '#185189', width: 2 }) }) taxlotStyle = () => new Style({ stroke: new Stroke({ color: '#10A0A0', width: 2 }) }) + footprintStyle = () => + new Style({ stroke: new Stroke({ color: '#1e3a5f', width: 2 }), fill: new Fill({ color: 'rgba(30, 58, 95, 0.2)' }) }) + + footprintSource(records: State[]) { + if (!this._footprintColumnName) return new VectorSource() + const features = records.reduce((acc: Feature[], record) => { + const wkt = record[this._footprintColumnName] as string | undefined + if (wkt) { + try { + const feature = new WKT().readFeature(wkt, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857', + }) + feature.setProperties(record) + acc.push(feature) + } catch (e) { + console.error(`Failed to process footprint for id ${record.id}:`, e) + } + } + return acc + }, []) + + return new VectorSource({ features }) + } layerVisible(zIndex: number) { const layers = this.map.getLayers().getArray() @@ -533,23 +616,25 @@ export class MapComponent implements OnDestroy, OnInit { this.highlightDACs = !this.highlightDACs } - detailPageIcon(pointInfo: { property_view_id?: number; taxlot_view_id?: number }) { - const iconHtml = '' + navigateToDetail(viewId: number) { + const typePath = this.type === 'taxlots' ? 'taxlots' : 'properties' + void this._router.navigate(['/', typePath, viewId]) + } - if (this.type === 'properties') { - return `${iconHtml}` - } - return `${iconHtml}` + dismissPopup() { + this.popupInfo = null + this.popupOverlay?.setPosition(undefined) } - // DEVELOPER NOTE: popover element needs to be developed - showPointInfo(point, element) { - // const popInfo = point.getProperties(); - // const defaultKey = Object.keys(popInfo).find((key) => key.startsWith(this.defaultField)) - // const coordinates = point.getGeometry().getCoordinates() - // const content = `${popInfo[defaultKey]} ${this.detailPageIcon(popInfo)}` - // this.popupOverlay.setPosition(coordinates) - console.log('TODO: need to develop a popover element', point, element) + showPointInfo(point: Feature) { + const props = point.getProperties() as Record + const viewId = (this.type === 'taxlots' ? props.taxlot_view_id : props.property_view_id) as number + const coordinates = (point.getGeometry() as unknown as { getCoordinates: () => number[] }).getCoordinates() + + // Resolve the display field value. The key might be exact, space-normalized, or absent (extra data fields). + const displayValue = this._resolveDisplayValue(props) + this.popupInfo = { displayValue: displayValue || `ID: ${viewId}`, viewId } + this.popupOverlay?.setPosition(coordinates) } zoomOnCluster(points: Feature[]) { @@ -578,9 +663,9 @@ export class MapComponent implements OnDestroy, OnInit { } rerenderPoints(records: State[]) { - console.log('rerenderPoints', records) this.filteredRecords = records.length this.pointsLayer.setSource(this.pointsSource(records)) + this.footprintLayer.setSource(this.footprintSource(records)) if (this.type === 'properties') { this.hexBinLayer.setSource(this.hexBinSource(records)) this.propertyBBLayer.setSource(this.boundingBoxSource(records)) @@ -629,4 +714,56 @@ export class MapComponent implements OnDestroy, OnInit { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() } + + private _fetchGroupPropertyIds() { + if (!this.groupId) { + return of(undefined) + } + return this._groupsService.getProperties(this.orgId, this.groupId).pipe( + tap((props) => { + this._groupPropertyIds = props.map((p) => p.property_id) + }), + ) + } + + private _resolveDisplayValue(props: Record): string { + if (!this.displayFieldKey) return '' + + // For extra data fields, look in the nested extra_data object + if (this.displayFieldIsExtraData) { + const extraData = props.extra_data as Record | undefined + if (extraData) { + // Try column_name (e.g., "Nick Name") which is how extra_data keys are stored + const val = extraData[this.displayFieldColumnName] + if (val !== undefined) return this._toDisplayString(val) + } + } + + // Try top-level exact match + if (props[this.displayFieldKey] !== undefined) { + return this._toDisplayString(props[this.displayFieldKey]) + } + + // Try with spaces replaced by underscores (API normalizes spaces) + const normalized = this.displayFieldKey.replace(/ /g, '_') + if (props[normalized] !== undefined) { + return this._toDisplayString(props[normalized]) + } + + // Display field not found — try common fallbacks + const fallbacks = ['property_name', 'address_line_1', 'pm_property_id', 'custom_id_1'] + for (const fallback of fallbacks) { + const key = Object.keys(props).find((k) => k.startsWith(fallback)) + if (key) { + const val = this._toDisplayString(props[key]) + if (val) return val + } + } + + return '' + } + + private _toDisplayString(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' ? String(value) : '' + } } diff --git a/src/app/modules/inventory/inventory.routes.ts b/src/app/modules/inventory/inventory.routes.ts index 34558944..7cdc4bc4 100644 --- a/src/app/modules/inventory/inventory.routes.ts +++ b/src/app/modules/inventory/inventory.routes.ts @@ -14,6 +14,13 @@ import { UbidsComponent, } from '../inventory-detail' import { ColumnDetailProfilesComponent } from '../inventory-detail/column-detail-profiles/column-detail-profiles.component' +import { GroupDashboardComponent } from '../inventory-list/groups/detail/dashboard/dashboard.component' +import { GroupDetailLayoutComponent } from '../inventory-list/groups/detail/group-detail-layout.component' +import { GroupMapComponent } from '../inventory-list/groups/detail/map/map.component' +import { GroupMetersComponent } from '../inventory-list/groups/detail/meters/meters.component' +import { GroupPropertiesComponent } from '../inventory-list/groups/detail/properties/properties.component' +import { ServiceDetailComponent } from '../inventory-list/groups/detail/systems/service-detail/service-detail.component' +import { GroupSystemsComponent } from '../inventory-list/groups/detail/systems/systems.component' import { SummaryComponent } from '../inventory-list/summary/summary.component' import type { InventoryType } from './inventory.types' @@ -39,6 +46,20 @@ export default [ title: 'Groups', component: GroupsComponent, }, + { + path: 'groups/:groupId', + title: 'Group Detail', + component: GroupDetailLayoutComponent, + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', title: 'Dashboard', component: GroupDashboardComponent }, + { path: 'properties', title: 'Properties', component: GroupPropertiesComponent }, + { path: 'systems', title: 'Systems & Services', component: GroupSystemsComponent }, + { path: 'systems/services/:systemId/:serviceId', title: 'Service Detail', component: ServiceDetailComponent }, + { path: 'meters', title: 'Meters', component: GroupMetersComponent }, + { path: 'map', title: 'Map', component: GroupMapComponent }, + ], + }, { path: 'column-list-profiles', title: 'Column Profiles',