From 5dc2dfa4e05eeb851b14cd56cb1cd1b44c4dc4b9 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:16:17 -0600 Subject: [PATCH 1/8] scenarios and measure parity plus enhancement --- public/i18n/en_US.json | 35 +++ public/i18n/es.json | 35 +++ public/i18n/fr_CA.json | 35 +++ src/@seed/api/scenario/scenario.types.ts | 27 ++- .../detail/grid/scenarios-grid.component.html | 82 +++++-- .../detail/grid/scenarios-grid.component.ts | 222 ++++++++++++++---- 6 files changed, 369 insertions(+), 67 deletions(-) diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index 60522889..b440c9e5 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -144,12 +144,17 @@ "Analysis Name (User Defined)": "Analysis Name (User Defined)", "Analysis State": "Analysis State", "Analysis View": "Analysis View", + "Annual Cost Savings": "Annual Cost Savings", "Annual Report": "Annual Report", + "Annual electricity savings": "Annual electricity savings", + "Annual natural gas savings": "Annual natural gas savings", + "Annual peak electricity reduction": "Annual peak electricity reduction", "Applied by a cross-cycle data quality check from the Portfolio Summary page": "Applied by a cross-cycle data quality check from the Portfolio Summary page", "Apply Profile": "Apply Profile", "Are you absolutely sure you want to delete the organization": "Are you absolutely sure you want to delete the organization", "Are you absolutely sure you want to delete the sub-organization": "Are you absolutely sure you want to delete the sub-organization", "Are you sure you want to delete": "Are you sure you want to delete", + "Are you sure you want to delete scenario \"{{scenarioName}}\"?": "Are you sure you want to delete scenario \"{{scenarioName}}\"?", "Are you sure you want to delete the data file": "Are you sure you want to delete the data file", "Are you sure you want to delete the data set": "Are you sure you want to delete the data set", "Are you sure you want to delete the filter group": "Are you sure you want to delete the filter group", @@ -263,6 +268,9 @@ "Cancel": "Cancel", "Cannot delete protected objects while related objects still exist": "Cannot delete protected objects while related objects still exist", "Cannot delete this access level instance.": "Cannot delete this access level instance.", + "Capital Replacement Cost": "Capital Replacement Cost", + "Category": "Category", + "Category Affected": "Category Affected", "Change Inventory Access Level": "Change Inventory Access Level", "Change Merged Property": "Change Merged Property", "Change Merged Tax Lot": "Change Merged Tax Lot", @@ -321,6 +329,7 @@ "Compare UBIDs": "Compare UBIDs", "Comparing UBIDs Complete": "Comparing UBIDs Complete", "Complete": "Complete", + "Completed": "Completed", "Compliance": "Compliance", "Compliance Columns": "Compliance Columns", "Compliance Cycle Year Column": "Compliance Cycle Year Column", @@ -513,6 +522,7 @@ "Delete Meter": "Delete Meter", "Delete Organization": "Delete Organization", "Delete Plan Run": "Delete Plan Run", + "Delete Scenario": "Delete Scenario", "Delete Selected": "Delete Selected", "Delete Sub-Organization": "Delete Sub-Organization", "Delete User?": "Delete User?", @@ -538,6 +548,7 @@ "Development Team": "Development Team", "Diesel": "Diesel", "Disabled": "Disabled", + "Discarded": "Discarded", "Dismiss": "Dismiss", "Display Columns": "Display Columns", "Display Fields": "Display Fields", @@ -653,6 +664,7 @@ "Error Processing Data": "Error Processing Data", "Error importing from ESPM": "Error importing from ESPM", "Errors occurred while importing access level instances:": "Errors occurred while importing access level instances:", + "Evaluated": "Evaluated", "Example": "Example", "Example Access Level Tree": "Example Access Level Tree", "Example Usage": "Example Usage", @@ -735,6 +747,7 @@ "Filter by label:": "Filter by label:", "Finding inventory matches for": "Finding inventory matches for", "Finding inventory matches.": "Finding inventory matches.", + "First Cost": "First Cost", "First Name": "First Name", "First Name required": "First Name required", "First Name:": "First Name:", @@ -878,6 +891,7 @@ "Import setting:": "Import setting:", "Important": "Important", "Imported": "Imported", + "In Progress": "In Progress", "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", @@ -887,7 +901,9 @@ "Includes Elements with Scope 1 Emissions Only": "Includes Elements with Scope 1 Emissions Only", "Indication Label": "Indication Label", "Ineligible": "Ineligible", + "Initiated": "Initiated", "Insights": "Insights", + "Installation Cost": "Installation Cost", "Installation Date": "Installation Date", "Integer": "Integer", "Interval": "Interval", @@ -979,6 +995,7 @@ "MOVE_INVENTORY_ALI_TXT": "Please select an Access Level and Access Level Instance to move {num} {label} to", "MULTIPLE_COL_MOD_OPTIONS": "From the table below, you may modify the columns in the following ways: ", "MULTI_FILE_EXCEL_NOT_SUPPORTED": "Note:<\/strong> only the first sheet of multi-sheet Excel files will be imported.", + "MV": "MV", "Main": "Main", "Main Contact Settings": "Main Contact Settings", "Maintenance": "Maintenance", @@ -1015,11 +1032,13 @@ "Matching List": "Matching List", "Matching Results for file": "Matching Results for file", "Matching criteria columns are given sort priority": "Matching criteria columns are given sort priority", + "Material Cost": "Material Cost", "Max": "Max", "Maximum": "Maximum", "Measurement Units": "Measurement Units", "Measurement unit display for areas": "Measurement unit display for areas", "Measurement unit display for energy use intensities (EUI)": "Measurement unit display for energy use intensities (EUI)", + "Measures": "Measures", "Member": "Member", "Member Access Level": "Member Access Level", "Member Access Level Instance": "Member Access Level Instance", @@ -1067,6 +1086,7 @@ "Must Include": "Must Include", "Must include all selected labels (AND)": "Must include all selected labels (AND)", "Must include at least one selected label (OR)": "Must include at least one selected label (OR)", + "N\/A": "N\/A", "NEW_COLUMN_REPLACE_DATA_WARNING": "A new column will be created, and\/or existing data can be replaced.", "NON_UNIQUE_DATA_LOGGER_DISPLAY_NAME": "Display name must be unique.", "NOT_GEOCODED_PREVIOUSLY": "Not geocoded previously", @@ -1314,6 +1334,7 @@ "Property Visibility": "Property Visibility", "Property and Tax Lot Data": "Property and Tax Lot Data", "Property updated from ESPM successfully.": "Property updated from ESPM successfully.", + "Proposed": "Proposed", "Public Data Sharing": "Public Data Sharing", "Public Disclosure - Sharing Your Data": "Public Disclosure - Sharing Your Data", "Public Query Threshold": "Public Query Threshold", @@ -1333,6 +1354,7 @@ "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", + "Recommended": "Recommended", "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", @@ -1381,6 +1403,7 @@ "Reset Defaults": "Reset Defaults", "Reset all passwords": "Reset all passwords", "Residential": "Residential", + "Residual Value": "Residual Value", "Resolution": "Resolution", "Restore Default Rules": "Restore Default Rules", "Results": "Results", @@ -1526,6 +1549,7 @@ "Saving access levels": "Saving access levels", "Saving…": "Saving…", "Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)\": \"Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)": "Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)\": \"Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)", + "Scenarios & Measures": "Scenarios & Measures", "Schedule Weekly Update": "Schedule Weekly Update", "Scheduled Daily Update": "Scheduled Daily Update", "Scope of properties included in this plan run": "Scope of properties included in this plan run", @@ -1572,6 +1596,7 @@ "Select the template that defines the energy and compliance columns": "Select the template that defines the energy and compliance columns", "Select the type of file(s) you would like to add to": "Select the type of file(s) you would like to add to", "Select which columns to include in the list:": "Select which columns to include in the list:", + "Selected": "Selected", "Selected Properties": "Selected Properties", "Selected Taxlots": "Selected Taxlots", "Selecting Fields to Share": "Selecting Fields to Share", @@ -1624,6 +1649,7 @@ "State": "State", "State (Property)": "State (Property)", "State (Tax Lot)": "State (Tax Lot)", + "Status": "Status", "Status Label Benchmark Field": "Status Label Benchmark Field", "Steam Energy Usage Column": "Steam Energy Usage Column", "Step 1: Add Access Levels": "Step 1: Add Access Levels", @@ -1758,6 +1784,8 @@ "Total New or Acquired": "Total New or Acquired", "Total Passing Checks": "Total Passing Checks", "Total Properties": "Total Properties", + "Total annual cost savings of the scenario's measures": "Total annual cost savings of the scenario's measures", + "Total first cost of the scenario's measures": "Total first cost of the scenario's measures", "Total rows in the import file": "Total rows in the import file", "Transaction": "Transaction", "Transactions": "Transactions", @@ -1810,6 +1838,7 @@ "Unknown": "Unknown", "Unmerge Last": "Unmerge Last", "Unmerge complete.": "Unmerge complete.", + "Unsatisfactory": "Unsatisfactory", "Unsuccessful Exports": "Unsuccessful Exports", "Until last date of": "Until last date of", "Update": "Update", @@ -1850,6 +1879,7 @@ "Uploading your data": "Uploading your data", "Uploading your data.": "Uploading your data.", "Use Description": "Use Description", + "Useful Life (years)": "Useful Life (years)", "User": "User", "User \"{{email}}\" added to organization": "User \"{{email}}\" added to organization", "User \"{{email}}\" created": "User \"{{email}}\" created", @@ -1868,6 +1898,7 @@ "Valid email required": "Valid email required", "Value": "Value", "Verification Code": "Verification Code", + "Verified": "Verified", "Verify": "Verify", "Version": "Version", "View Compliance Tracking": "View Compliance Tracking", @@ -1961,6 +1992,7 @@ "access level instances will be removed, including all properties attached to those instances": "access level instances will be removed, including all properties attached to those instances", "active": "active", "and": "and", + "annual savings": "annual savings", "api key": "api key", "approved_date": "approved_date", "auto-mapping energy data": "auto-mapping energy data", @@ -2003,6 +2035,7 @@ "end_date": "end_date", "endpoints see the": "endpoints see the", "ex: joe@company.com": "ex: joe@company.com", + "first cost": "first cost", "first name": "first name", "for your SEED Platform user account": "for your SEED Platform user account", "goal": "Goal", @@ -2010,8 +2043,10 @@ "green": "green", "has been uploaded to": "has been uploaded to", "header": "header", + "kBtu": "kBtu", "kBtu\/m²\/year": "kBtu\/m²\/year", "kBtu\/sq. ft.\/year": "kBtu\/sq. ft.\/year", + "kW": "kW", "kWh\/m²\/year": "kWh\/m²\/year", "last name": "last name", "light blue": "light blue", diff --git a/public/i18n/es.json b/public/i18n/es.json index 864100d1..f0f7f5f2 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -144,12 +144,17 @@ "Analysis Name (User Defined)": "Nombre del análisis (definido por el usuario)", "Analysis State": "Análisis Estado", "Analysis View": "Vista de análisis", + "Annual Cost Savings": "Ahorro de costes anual", "Annual Report": "Informe anual", + "Annual electricity savings": "Ahorro anual de electricidad", + "Annual natural gas savings": "Ahorro anual de gas natural", + "Annual peak electricity reduction": "Reducción anual de la electricidad en horas punta", "Applied by a cross-cycle data quality check from the Portfolio Summary page": "Aplicado mediante una verificación de calidad de datos interciclo desde la página Resumen de cartera", "Apply Profile": "Aplicar perfil", "Are you absolutely sure you want to delete the organization": "¿Está seguro de que desea eliminar la organización?", "Are you absolutely sure you want to delete the sub-organization": "¿Está seguro de que desea eliminar la suborganización?", "Are you sure you want to delete": "¿Estás seguro de que quieres borrar", + "Are you sure you want to delete scenario \"{{scenarioName}}\"?": "¿Estás seguro de que quieres eliminar el escenario \"{{scenarioName}}\"?", "Are you sure you want to delete the data file": "¿Está seguro de que desea eliminar el archivo de datos", "Are you sure you want to delete the data set": "¿Está seguro de que desea eliminar el conjunto de datos", "Are you sure you want to delete the filter group": "¿Está seguro de que desea eliminar el grupo de filtros?", @@ -263,6 +268,9 @@ "Cancel": "Cancelar", "Cannot delete protected objects while related objects still exist": "No se pueden eliminar objetos protegidos mientras sigan existiendo objetos relacionados", "Cannot delete this access level instance.": "No se puede eliminar esta instancia de nivel de acceso.", + "Capital Replacement Cost": "Costo de reemplazo de capital", + "Category": "Categoría", + "Category Affected": "Categoría afectada", "Change Inventory Access Level": "Cambiar el nivel de acceso al inventario", "Change Merged Property": "Cambiar propiedad fusionada", "Change Merged Tax Lot": "Cambio Lote fiscal fusionado", @@ -321,6 +329,7 @@ "Compare UBIDs": "Comparar UBIDs", "Comparing UBIDs Complete": "Comparación de UBIDs Completa", "Complete": "Complete", + "Completed": "Terminado", "Compliance": "Conformidad", "Compliance Columns": "Columnas de cumplimiento", "Compliance Cycle Year Column": "Columna del año del ciclo de cumplimiento", @@ -513,6 +522,7 @@ "Delete Meter": "Eliminar medidor", "Delete Organization": "Borrar organización", "Delete Plan Run": "Eliminar Plan Ejecutar", + "Delete Scenario": "Eliminar escenario", "Delete Selected": "Borrar seleccionados", "Delete Sub-Organization": "Suprimir suborganización", "Delete User?": "¿Borrar usuario?", @@ -538,6 +548,7 @@ "Development Team": "Equipo de desarrollo", "Diesel": "Diesel", "Disabled": "Desactivado", + "Discarded": "Descartado", "Dismiss": "Desestimar", "Display Columns": "Columnas de visualización", "Display Fields": "Campos de visualización", @@ -653,6 +664,7 @@ "Error Processing Data": "Datos de procesamiento de errores", "Error importing from ESPM": "Error al importar desde ESPM", "Errors occurred while importing access level instances:": "Se produjeron errores al importar instancias de nivel de acceso:", + "Evaluated": "Evaluado", "Example": "Ejemplo", "Example Access Level Tree": "Ejemplo de árbol de niveles de acceso", "Example Usage": "Ejemplo de uso", @@ -735,6 +747,7 @@ "Filter by label:": "Filtrar por etiqueta:", "Finding inventory matches for": "Encontrar coincidencias de inventario para", "Finding inventory matches.": "Encontrar coincidencias de inventario.", + "First Cost": "Costo inicial", "First Name": "Nombre", "First Name required": "Nombre requerido", "First Name:": "Nombre de pila:", @@ -878,6 +891,7 @@ "Import setting:": "Ajuste de importación:", "Important": "Importante", "Imported": "Importado", + "In Progress": "En curso", "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", @@ -887,7 +901,9 @@ "Includes Elements with Scope 1 Emissions Only": "Incluye sólo elementos con emisiones de Alcance 1", "Indication Label": "Indicación Etiqueta", "Ineligible": "No subvencionable", + "Initiated": "Iniciado", "Insights": "Perspectivas", + "Installation Cost": "Costo de instalación", "Installation Date": "Fecha de instalación", "Integer": "Entero", "Interval": "Intervalo", @@ -979,6 +995,7 @@ "MOVE_INVENTORY_ALI_TXT": "Por favor, seleccione un Nivel de Acceso y una Instancia de Nivel de Acceso para mover {num} {label} a", "MULTIPLE_COL_MOD_OPTIONS": "A partir de la tabla siguiente, puede modificar las columnas de las siguientes maneras: ", "MULTI_FILE_EXCEL_NOT_SUPPORTED": "Nota:<\/strong> sólo se importará la primera hoja de los archivos Excel de varias hojas.", + "MV": "MV", "Main": "Principal", "Main Contact Settings": "Configuración del contacto principal", "Maintenance": "Mantenimiento", @@ -1015,11 +1032,13 @@ "Matching List": "Lista de correspondencias", "Matching Results for file": "Resultados del archivo", "Matching criteria columns are given sort priority": "Las columnas que cumplen los criterios tienen prioridad de ordenación.", + "Material Cost": "Costo del material", "Max": "Max", "Maximum": "Máximo", "Measurement Units": "Unidades de medida", "Measurement unit display for areas": "Visualización de la unidad de medida por áreas", "Measurement unit display for energy use intensities (EUI)": "Visualización de la unidad de medida de las intensidades de uso de la energía (EUI)", + "Measures": "Medidas", "Member": "Miembro", "Member Access Level": "Nivel de acceso", "Member Access Level Instance": "Miembro Nivel de acceso Instancia", @@ -1067,6 +1086,7 @@ "Must Include": "Debe incluir", "Must include all selected labels (AND)": "Debe incluir todas las etiquetas seleccionadas (AND)", "Must include at least one selected label (OR)": "Debe incluir al menos una etiqueta seleccionada (OR)", + "N\/A": "N\/A", "NEW_COLUMN_REPLACE_DATA_WARNING": "Se creará una nueva columna y\/o se podrán sustituir los datos existentes.", "NON_UNIQUE_DATA_LOGGER_DISPLAY_NAME": "El nombre para mostrar debe ser único.", "NOT_GEOCODED_PREVIOUSLY": "No geocodificado previamente", @@ -1314,6 +1334,7 @@ "Property Visibility": "Visibilidad de la propiedad", "Property and Tax Lot Data": "Datos sobre propiedades y lotes fiscales", "Property updated from ESPM successfully.": "La propiedad se actualizó correctamente desde ESPM.", + "Proposed": "Propuesto", "Public Data Sharing": "Intercambio de datos públicos", "Public Disclosure - Sharing Your Data": "Divulgación pública - Compartir sus datos", "Public Query Threshold": "Umbral de consulta pública", @@ -1333,6 +1354,7 @@ "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", + "Recommended": "Recomendado", "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", @@ -1381,6 +1403,7 @@ "Reset Defaults": "Restablecer valores predeterminados", "Reset all passwords": "Restablecer todas las contraseñas", "Residential": "Residencial", + "Residual Value": "Valor residual", "Resolution": "Resolución", "Restore Default Rules": "Restaurar reglas por defecto", "Results": "Resultados", @@ -1526,6 +1549,7 @@ "Saving access levels": "Guardando niveles de acceso", "Saving…": "Ahorro…", "Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)\": \"Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)": "Escanee el código QR a continuación usando su aplicación de autenticación preferida (por ejemplo: Google Authenticator, Microsoft Authenticator, Authy, etc...)\": \"Escanee el código QR a continuación usando su aplicación de autenticación preferida (por ejemplo: Google Authenticator, Microsoft Authenticator, Authy, etc...)", + "Scenarios & Measures": "Escenarios y medidas", "Schedule Weekly Update": "Programar la actualización semanal", "Scheduled Daily Update": "Actualización diaria programada", "Scope of properties included in this plan run": "Alcance de las propiedades incluidas en este plan", @@ -1572,6 +1596,7 @@ "Select the template that defines the energy and compliance columns": "Seleccione la plantilla que define las columnas de energía y cumplimiento.", "Select the type of file(s) you would like to add to": "Seleccione el tipo de archivo(s) que desea añadir a", "Select which columns to include in the list:": "Seleccione las columnas que desea incluir en la lista:", + "Selected": "Seleccionado", "Selected Properties": "Propiedades seleccionadas", "Selected Taxlots": "Parcelas fiscales seleccionadas", "Selecting Fields to Share": "Seleccionar campos para compartir", @@ -1624,6 +1649,7 @@ "State": "Estado", "State (Property)": "Estado (Propiedad)", "State (Tax Lot)": "Estado (Lote fiscal)", + "Status": "Estado", "Status Label Benchmark Field": "Campo de referencia de etiqueta de estado", "Steam Energy Usage Column": "Columna de uso de energía de vapor", "Step 1: Add Access Levels": "Paso 1: Añadir niveles de acceso", @@ -1758,6 +1784,8 @@ "Total New or Acquired": "Total de unidades nuevas o adquiridas", "Total Passing Checks": "Total de pruebas superadas", "Total Properties": "Total Propiedades", + "Total annual cost savings of the scenario's measures": "Ahorro total anual de costes de las medidas del escenario", + "Total first cost of the scenario's measures": "Coste inicial total de las medidas del escenario", "Total rows in the import file": "Total de filas del fichero de importación", "Transaction": "Transacción", "Transactions": "Actas", @@ -1810,6 +1838,7 @@ "Unknown": "Desconocido", "Unmerge Last": "Fusión final", "Unmerge complete.": "Descombinación completada.", + "Unsatisfactory": "Insatisfactorio", "Unsuccessful Exports": "Exportaciones fallidas", "Until last date of": "Hasta la última fecha de", "Update": "Actualizar", @@ -1850,6 +1879,7 @@ "Uploading your data": "Cargar sus datos", "Uploading your data.": "Carga de datos.", "Use Description": "Uso Descripción", + "Useful Life (years)": "Vida útil (años)", "User": "Usuario", "User \"{{email}}\" added to organization": "Usuario \"{{email}}\"añadido a la organización", "User \"{{email}}\" created": "Usuario \"{{email}}\" creado", @@ -1868,6 +1898,7 @@ "Valid email required": "Se requiere un correo electrónico válido.", "Value": "Valor", "Verification Code": "Código de verificación", + "Verified": "Verificado", "Verify": "Verificar", "Version": "Versión", "View Compliance Tracking": "Ver el seguimiento del cumplimiento", @@ -1961,6 +1992,7 @@ "access level instances will be removed, including all properties attached to those instances": "las instancias de nivel de acceso se eliminarán, incluidas todas las propiedades adjuntas a esas instancias", "active": "activo", "and": "y", + "annual savings": "ahorros anuales", "api key": "clave api", "approved_date": "fecha_aprobada", "auto-mapping energy data": "cartografía automática de los datos energéticos", @@ -2003,6 +2035,7 @@ "end_date": "fecha_final", "endpoints see the": "consulte los puntos finales", "ex: joe@company.com": "ej: joe@company.com", + "first cost": "primer costo", "first name": "Nombre de pila", "for your SEED Platform user account": "para su cuenta de usuario de la Plataforma SEED", "goal": "Objetivo", @@ -2010,8 +2043,10 @@ "green": "verde", "has been uploaded to": "se ha cargado en", "header": "cabecera", + "kBtu": "kBtu", "kBtu\/m²\/year": "kBtu\/m²\/año", "kBtu\/sq. ft.\/year": "kBtu\/pie cuadrado\/año", + "kW": "kW", "kWh\/m²\/year": "kWh\/m²\/año", "last name": "apellido", "light blue": "azul claro", diff --git a/public/i18n/fr_CA.json b/public/i18n/fr_CA.json index 2d1efbb0..209e948a 100644 --- a/public/i18n/fr_CA.json +++ b/public/i18n/fr_CA.json @@ -144,12 +144,17 @@ "Analysis Name (User Defined)": "Nom de l'analyse (défini par l'utilisateur)", "Analysis State": "État d'analyse", "Analysis View": "Vue d'analyse", + "Annual Cost Savings": "Économies annuelles", "Annual Report": "Rapport annuel", + "Annual electricity savings": "économies d'électricité annuelles", + "Annual natural gas savings": "Économies annuelles de gaz naturel", + "Annual peak electricity reduction": "Réduction annuelle de la consommation d'électricité de pointe", "Applied by a cross-cycle data quality check from the Portfolio Summary page": "Appliqué par un contrôle de qualité des données inter-cycles à partir de la page Résumé du portefeuille", "Apply Profile": "Appliquer le profil", "Are you absolutely sure you want to delete the organization": "Êtes-vous absolument sûr de vouloir supprimer l'organisation", "Are you absolutely sure you want to delete the sub-organization": "Êtes-vous absolument sûr de vouloir supprimer la sous-organisation", "Are you sure you want to delete": "Etes-vous sûr de vouloir supprimer", + "Are you sure you want to delete scenario \"{{scenarioName}}\"?": "Êtes-vous sûr de vouloir supprimer le scénario \"{{scenarioName}}\"?", "Are you sure you want to delete the data file": "Êtes-vous sûr de vouloir supprimer le fichier de données", "Are you sure you want to delete the data set": "Êtes-vous sûr de vouloir supprimer le jeu de données", "Are you sure you want to delete the filter group": "Êtes-vous sûr de vouloir supprimer le groupe de filtres", @@ -263,6 +268,9 @@ "Cancel": "Annuler", "Cannot delete protected objects while related objects still exist": "Impossible de supprimer des objets protégés alors que des objets apparentés existent toujours", "Cannot delete this access level instance.": "Impossible de supprimer cette instance de niveau d'accès.", + "Capital Replacement Cost": "coût de remplacement du capital", + "Category": "Catégorie", + "Category Affected": "Catégorie concernée", "Change Inventory Access Level": "Modifier le niveau d'accès à l'inventaire", "Change Merged Property": "Modifier la propriété fusionnée", "Change Merged Tax Lot": "Modifier le lot d'impôt fusionnée", @@ -321,6 +329,7 @@ "Compare UBIDs": "Comparez les UBID", "Comparing UBIDs Complete": "Comparaison des UBID terminée", "Complete": "Complète", + "Completed": "Complété", "Compliance": "Conformité", "Compliance Columns": "Colonnes de conformité", "Compliance Cycle Year Column": "Colonne Année du cycle de conformité", @@ -513,6 +522,7 @@ "Delete Meter": "Supprimer le compteur", "Delete Organization": "Supprimer l'organisation", "Delete Plan Run": "Supprimer le plan d'exécution", + "Delete Scenario": "Supprimer le scénario", "Delete Selected": "Supprimer la sélection", "Delete Sub-Organization": "Supprimer la sous-organisation", "Delete User?": "Supprimer l'utilisateur?", @@ -538,6 +548,7 @@ "Development Team": "Équipe de développement", "Diesel": "Diesel", "Disabled": "Désactivé", + "Discarded": "Écarté", "Dismiss": "Rejeter", "Display Columns": "Afficher les colonnes", "Display Fields": "Afficher les champs", @@ -653,6 +664,7 @@ "Error Processing Data": "Erreur lors du traitement des données", "Error importing from ESPM": "Erreur lors de l'importation depuis ESPM", "Errors occurred while importing access level instances:": "Des erreurs se sont produites lors de l'importation des instances de niveau d'accès :", + "Evaluated": "Évalué", "Example": "Exemple", "Example Access Level Tree": "Exemple de graphe en arbre de niveaux d'accès", "Example Usage": "Exemple d'utilisation", @@ -735,6 +747,7 @@ "Filter by label:": "Filtrer par étiquette:", "Finding inventory matches for": "Trouver des correspondances d'inventaire pour", "Finding inventory matches.": "Trouver des correspondances d'inventaire.", + "First Cost": "Premier coût", "First Name": "Prénom", "First Name required": "Prénom requis", "First Name:": "Prénom:", @@ -878,6 +891,7 @@ "Import setting:": "Paramètre d'importation :", "Important": "Important", "Imported": "Importé", + "In Progress": "En cours", "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", @@ -887,7 +901,9 @@ "Includes Elements with Scope 1 Emissions Only": "Comprend uniquement les éléments dont les émissions relèvent du champ d'application 1", "Indication Label": "Étiquette d'indication", "Ineligible": "Inéligible", + "Initiated": "Initié", "Insights": "Connaissances", + "Installation Cost": "Coût d'installation", "Installation Date": "Date d'installation", "Integer": "Entier", "Interval": "Intervalle", @@ -979,6 +995,7 @@ "MOVE_INVENTORY_ALI_TXT": "Veuillez sélectionner un niveau d'accès et une instance de niveau d'accès vers lesquels déplacer {num} {label}", "MULTIPLE_COL_MOD_OPTIONS": "Dans le tableau ci-dessous, vous pouvez modifier les colonnes des manières suivantes:", "MULTI_FILE_EXCEL_NOT_SUPPORTED": "Remarque:<\/strong> seule la première feuille de fichiers Excel de plusieurs feuilles sera importée.", + "MV": "MV", "Main": "Principale", "Main Contact Settings": "Principaux paramètres de contact", "Maintenance": "Maintenance", @@ -1015,11 +1032,13 @@ "Matching List": "Liste des Correspondances", "Matching Results for file": "Résultats correspondants pour le fichier", "Matching criteria columns are given sort priority": "Les colonnes correspondant aux critères sont triées en priorité.", + "Material Cost": "Coût des matériaux", "Max": "Max", "Maximum": "Maximum", "Measurement Units": "Unités de mesure", "Measurement unit display for areas": "Affichage de l'unité de mesure pour les zones", "Measurement unit display for energy use intensities (EUI)": "Affichage de l'unité de mesure des intensités d'utilisation d'énergie (EUI)", + "Measures": "Mesures", "Member": "Membre", "Member Access Level": "Niveau d'accès des membres", "Member Access Level Instance": "Membre Niveau d'accès Instance", @@ -1067,6 +1086,7 @@ "Must Include": "Doit inclure", "Must include all selected labels (AND)": "Doit inclure toutes les étiquettes sélectionnées (ET)", "Must include at least one selected label (OR)": "Doit inclure au moins une étiquette sélectionnée (OU)", + "N\/A": "N\/A", "NEW_COLUMN_REPLACE_DATA_WARNING": "Une nouvelle colonne sera créée et\/ou les données existantes peuvent être remplacées.", "NON_UNIQUE_DATA_LOGGER_DISPLAY_NAME": "Le nom d'affichage doit être unique.", "NOT_GEOCODED_PREVIOUSLY": "Non géocodé auparavant", @@ -1314,6 +1334,7 @@ "Property Visibility": "Visibilité de la propriété", "Property and Tax Lot Data": "Données foncières et fiscales", "Property updated from ESPM successfully.": "Propriété mise à jour avec succès depuis ESPM.", + "Proposed": "Proposition", "Public Data Sharing": "Partage de données publiques", "Public Disclosure - Sharing Your Data": "Divulgation publique – Partage de données", "Public Query Threshold": "Seuil de requête publique", @@ -1333,6 +1354,7 @@ "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", + "Recommended": "Recommandé", "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", @@ -1381,6 +1403,7 @@ "Reset Defaults": "Réinitialiser par défaut", "Reset all passwords": "Réinitialiser tous les mots de passe", "Residential": "Résidentiel", + "Residual Value": "Valeur résiduelle", "Resolution": "Résolution", "Restore Default Rules": "Restaurer les règles par défaut", "Results": "Résultats", @@ -1526,6 +1549,7 @@ "Saving access levels": "Enregistrement des niveaux d'accès", "Saving…": "Économie…", "Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)\": \"Scan the QR code below using your authenticator app of choice (e.g., Google Authenticator, Microsoft Authenticator, Authy, etc.)": "Scannez le code QR ci-dessous à l'aide de votre application d'authentification préférée (ex : Google Authenticator, Microsoft Authenticator, Authy, etc.) : « Scannez le code QR ci-dessous à l'aide de votre application d'authentification préférée (ex : Google Authenticator, Microsoft Authenticator, Authy, etc.) »", + "Scenarios & Measures": "Scénarios et mesures", "Schedule Weekly Update": "Planifier une mise à jour hebdomadaire", "Scheduled Daily Update": "Mise à jour programmée quotidienne", "Scope of properties included in this plan run": "Portée des propriétés incluses dans ce plan", @@ -1572,6 +1596,7 @@ "Select the template that defines the energy and compliance columns": "Sélectionnez le modèle qui définit les colonnes énergie et conformité", "Select the type of file(s) you would like to add to": "Sélectionnez le type de fichier (s) que vous souhaitez ajouter à", "Select which columns to include in the list:": "Sélectionnez les colonnes à inclure dans la liste:", + "Selected": "Choisi", "Selected Properties": "Propriétés Sélectionnées", "Selected Taxlots": "Lots d'impôt Sélectionnés", "Selecting Fields to Share": "Sélection des champs à partager", @@ -1624,6 +1649,7 @@ "State": "État", "State (Property)": "Etat (Propriété)", "State (Tax Lot)": "État (lot d'impôt)", + "Status": "Statut", "Status Label Benchmark Field": "Champ de référence pour l'étiquette d'état", "Steam Energy Usage Column": "Colonne d'utilisation de l'énergie de la vapeur", "Step 1: Add Access Levels": "Étape 1: ajouter des niveaux d'accès", @@ -1758,6 +1784,8 @@ "Total New or Acquired": "Total des nouveaux ou acquis", "Total Passing Checks": "Nombre total de contrôles réussis", "Total Properties": "Total des propriétés", + "Total annual cost savings of the scenario's measures": "Économies annuelles totales des mesures du scénario", + "Total first cost of the scenario's measures": "Coût total initial des mesures du scénario", "Total rows in the import file": "Nombre total de lignes dans le fichier d'importation", "Transaction": "Transaction", "Transactions": "Transactions", @@ -1810,6 +1838,7 @@ "Unknown": "Inconnue", "Unmerge Last": "Défaire la fusion Dernière", "Unmerge complete.": "Fusion terminée.", + "Unsatisfactory": "Insatisfaisant", "Unsuccessful Exports": "Exportations infructueuses", "Until last date of": "Jusqu'à la dernière date de", "Update": "Mise à jour", @@ -1850,6 +1879,7 @@ "Uploading your data": "Télécharger vos données", "Uploading your data.": "Téléchargement de vos données.", "Use Description": "Description de l’emploi", + "Useful Life (years)": "Durée de vie utile (années)", "User": "Utilisateur", "User \"{{email}}\" added to organization": "Utilisateur \"{{email}}\" ajouté à l'organisation", "User \"{{email}}\" created": "Utilisateur \"{{email}}\" créé", @@ -1868,6 +1898,7 @@ "Valid email required": "Adresse e-mail valide requise", "Value": "Valeur", "Verification Code": "Le code de vérification", + "Verified": "Vérifié", "Verify": "Vérifier", "Version": "Version", "View Compliance Tracking": "Afficher le suivi de la conformité", @@ -1961,6 +1992,7 @@ "access level instances will be removed, including all properties attached to those instances": "les instances de niveau d'accès seront supprimées, y compris toutes les propriétés attachées à ces instances", "active": "actif", "and": "et", + "annual savings": "épargne annuelle", "api key": "clé API", "approved_date": "date approuvée", "auto-mapping energy data": "mappant automatiquement les données d'énergie", @@ -2003,6 +2035,7 @@ "end_date": "date de fin", "endpoints see the": "points de terminaison voir le", "ex: joe@company.com": "ex: joe@company.com", + "first cost": "premier coût", "first name": "Prénom", "for your SEED Platform user account": "pour votre compte utilisateur SEED Platform", "goal": "Objectif", @@ -2010,8 +2043,10 @@ "green": "vert", "has been uploaded to": "a été téléchargé à", "header": "en-tête", + "kBtu": "kBtu", "kBtu\/m²\/year": "kBtu\/m²\/année", "kBtu\/sq. ft.\/year": "kBtu\/pi. ca.\/année", + "kW": "kW", "kWh\/m²\/year": "kWh\/m²\/année", "last name": "nom de famille", "light blue": "bleu clair", diff --git a/src/@seed/api/scenario/scenario.types.ts b/src/@seed/api/scenario/scenario.types.ts index 366d04cc..cecd798e 100644 --- a/src/@seed/api/scenario/scenario.types.ts +++ b/src/@seed/api/scenario/scenario.types.ts @@ -1,10 +1,35 @@ +// Mirrors PropertyMeasureSerializer in the backend +export type Measure = { + [key: string]: unknown; + annual_cost_savings: number | null; + application_scale: string; + category: string; + category_affected: string; + category_display_name: string; + cost_capital_replacement: number | null; + cost_installation: number | null; + cost_material: number | null; + cost_mv: number | null; + cost_residual_value: number | null; + cost_total_first: number | null; + description: string | null; + display_name: string; + id: number; + implementation_status: string; + measure_id: string; + name: string; + recommended: boolean; + scenario_id: number; + useful_life: number | null; +} + export type Scenario = { [key: string]: unknown; annual_electricity_savings: number; annual_natural_gas_savings: number; annual_peak_electricity_reduction: number; id: number; - measures: Record[]; + measures: Measure[]; name: string; propertyState: number; } diff --git a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html index 211977ad..ba831c1e 100644 --- a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html +++ b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html @@ -1,39 +1,79 @@ -
-
- - - Scenarios & Measures +
+
+ + {{ t('Scenarios & Measures') }}
@for (entry of rowDataEntries; track $index) { -
{{ entry.date }}
+
+ + {{ entry.date }} +
@for (scenario of entry.rowData; track scenario.id) { - - - {{ scenario.name }} - - Elec: {{ scenario.annual_electricity_savings ?? 'N/A' }} kBtu - Peak: {{ scenario.annual_peak_electricity_reduction ?? 'N/A' }} kW - Gas: {{ scenario.annual_natural_gas_savings ?? 'N/A' }} kBtu - {{ scenario.measures?.length ?? 0 }} measure(s) + + + + + {{ scenario.name }} + + + + + {{ (scenario.annual_electricity_savings | number: '1.0-0') ?? t('N/A') }} + {{ t('kBtu') }} + + + + {{ (scenario.annual_peak_electricity_reduction | number: '1.0-0') ?? t('N/A') }} + {{ t('kW') }} + + + + {{ (scenario.annual_natural_gas_savings | number: '1.0-0') ?? t('N/A') }} + {{ t('kBtu') }} + + + + {{ (getTotalFirstCost(scenario) | currency: 'USD' : 'symbol' : '1.0-0') ?? t('N/A') }} + {{ t('first cost') }} + + + + {{ + (getTotalAnnualCostSavings(scenario) | currency: 'USD' : 'symbol' : '1.0-0') ?? t('N/A') + }} + {{ t('annual savings') }} + + + @for (status of getMeasureStatuses(scenario); track status.status) { + {{ status.count }} {{ status.label }} + } + - @if (scenario.measures?.length) { + +
+ + {{ t('Measures') }} +
+ - } @else { -
No measures in this scenario.
- } -
- -
+
+ +
+
}
diff --git a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts index 677c852c..9ad2ec27 100644 --- a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts @@ -1,89 +1,209 @@ import { CommonModule } from '@angular/common' -import type { OnChanges, SimpleChanges } from '@angular/core' +import type { OnChanges, OnDestroy, SimpleChanges } from '@angular/core' import { Component, EventEmitter, inject, Input, Output } from '@angular/core' +import { TranslocoDirective, TranslocoService } from '@jsverse/transloco' import { AgGridAngular } from 'ag-grid-angular' -import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import type { Scenario } from '@seed/api' +import type { ColDef, GridApi, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' +import { Subject, switchMap, takeUntil } from 'rxjs' +import type { Measure, Scenario } from '@seed/api' import { ScenarioService } from '@seed/api' import { MaterialImports } from '@seed/materials' import { ConfigService } from '@seed/services' import type { ViewResponse } from 'app/modules/inventory/inventory.types' -type FieldType = 'annual_electricity_savings' | 'annual_peak_electricity_reduction' | 'annual_natural_gas_savings' +type MeasureStatusCount = { status: string; label: string; count: number; classes: string } + +// Colors follow the measure's progress: proposed/evaluated are neutral-to-warm, completed/verified green, +// discarded/unsatisfactory muted or red. Class strings are literal so Tailwind's scanner keeps them. +const MEASURE_STATUS_CLASSES: Record = { + Proposed: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + Evaluated: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + Selected: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200', + Initiated: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200', + Discarded: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + 'In Progress': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + Completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + MV: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200', + Verified: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200', + Unsatisfactory: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', +} +const MEASURE_STATUS_DEFAULT_CLASSES = 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' + +const escapeHtml = (value: string): string => + value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + +const statusChipHtml = (status: string, label: string): string => { + const classes = MEASURE_STATUS_CLASSES[status] ?? MEASURE_STATUS_DEFAULT_CLASSES + return `${escapeHtml(label)}` +} @Component({ selector: 'seed-inventory-detail-scenarios-grid', templateUrl: './scenarios-grid.component.html', - imports: [CommonModule, AgGridAngular, MaterialImports], + imports: [CommonModule, AgGridAngular, MaterialImports, TranslocoDirective], }) -export class ScenariosGridComponent implements OnChanges { +export class ScenariosGridComponent implements OnChanges, OnDestroy { @Input() orgId: number @Input() view: ViewResponse @Input() viewId: number @Output() refreshView = new EventEmitter() private _configService = inject(ConfigService) private _scenarioService = inject(ScenarioService) - columnDefs: ColDef[] = [] - measureColumnDefs: ColDef[] = [ - { field: 'category', headerName: 'Category', flex: 1 }, - { field: 'display_name', headerName: 'Name', flex: 2 }, - { field: 'implementation_status', headerName: 'Status', flex: 1 }, - { field: 'description', headerName: 'Description', flex: 2 }, - ] - gridApi: GridApi + private _transloco = inject(TranslocoService) + private readonly _unsubscribeAll$ = new Subject() + measureColumnDefs: ColDef[] = [] gridTheme$ = this._configService.gridTheme$ scenarios: Scenario[] rowDataEntries: { date: string; rawDate: number; rowData: Scenario[] }[] = [] + private _gridApis = new Map() + + constructor() { + // Build columns up front so the grid never initializes before the first language load resolves + this.setMeasureColumnDefs() + // ag-grid headers are plain strings, so they can't re-render themselves the way *transloco can. + // langChanges$ fires before the new language file is fetched, so wait for the load to finish. + this._transloco.langChanges$ + .pipe( + switchMap((lang) => this._transloco.load(lang)), + takeUntil(this._unsubscribeAll$), + ) + .subscribe(() => { + this.setMeasureColumnDefs() + }) + } + ngOnChanges(changes: SimpleChanges): void { if (changes.view) { this.initScenarios() } } + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + initScenarios() { this.scenarios = this.view.state.scenarios - this.setColumnDefs() this.setGrid() } - setColumnDefs() { - this.columnDefs = [ - { field: 'id', hide: true }, - { field: 'name', headerName: 'Scenario' }, - { - field: 'annual_electricity_savings', - headerName: 'Electricity Savings (kBtu)', - valueGetter: this.withDefault('annual_electricity_savings'), - }, + setMeasureColumnDefs() { + const t = (key: string) => this._transloco.translate(key) + const currencyColumn = (field: string, headerName: string, minWidth: number): ColDef => ({ + field, + headerName, + flex: 1, + minWidth, + type: 'numericColumn', + valueFormatter: (params) => this.formatCurrency(params), + }) + // Order mirrors the legacy property detail measure table so the two UIs stay comparable + this.measureColumnDefs = [ + { field: 'category', headerName: t('Category'), flex: 1, minWidth: 130 }, + { field: 'display_name', headerName: t('Name'), flex: 2, minWidth: 180 }, { - field: 'annual_peak_electricity_reduction', - headerName: 'Peak Reduction (kW)', - valueGetter: this.withDefault('annual_peak_electricity_reduction'), + field: 'recommended', + headerName: t('Recommended'), + flex: 1, + minWidth: 140, + // Without this ag-grid infers a boolean type and swaps in a read-only checkbox, which both + // ignores the formatter below and looks editable in a read-only grid. + cellDataType: 'text', + valueFormatter: (params) => this.formatBoolean(params), }, { - field: 'annual_natural_gas_savings', - headerName: 'Gas Savings (kBTU)', - valueGetter: this.withDefault('annual_natural_gas_savings'), + field: 'implementation_status', + headerName: t('Status'), + flex: 1, + minWidth: 150, + cellRenderer: ({ value }: ICellRendererParams) => this.statusRenderer({ value }), }, + { field: 'category_affected', headerName: t('Category Affected'), flex: 1, minWidth: 170 }, + currencyColumn('cost_installation', t('Installation Cost'), 160), + currencyColumn('cost_material', t('Material Cost'), 150), + currencyColumn('cost_residual_value', t('Residual Value'), 150), + currencyColumn('cost_total_first', t('First Cost'), 130), + currencyColumn('annual_cost_savings', t('Annual Cost Savings'), 180), + currencyColumn('cost_capital_replacement', t('Capital Replacement Cost'), 200), + { field: 'description', headerName: t('Description'), flex: 2, minWidth: 200 }, { - headerName: 'Status of Measures', - valueGetter: ({ data }: { data: Scenario }) => `${data?.measures?.length || 0} Proposed`, + field: 'useful_life', + headerName: t('Useful Life (years)'), + flex: 1, + minWidth: 160, + type: 'numericColumn', + valueFormatter: (params) => this.formatNumber(params), }, - { field: 'actions', headerName: 'Actions', cellRenderer: this.actionRenderer, width: 90 }, ] } - withDefault = (field: FieldType) => { - return ({ data }: { data: Scenario }): unknown => data?.[field] ?? 'N/A' + onGridReady({ api }: GridReadyEvent, scenarioId: number): void { + this._gridApis.set(scenarioId, api) + // The grid is created while the panel is still opening, so refresh once layout has settled + this._refreshGridCells(scenarioId) + } + + onPanelExpanded(scenarioId: number): void { + this._refreshGridCells(scenarioId) + } + + // Sum of the scenario's measures' MeasureTotalFirstCost; null when no measure reports a cost + getTotalFirstCost(scenario: Scenario): number | null { + return this.sumMeasureField(scenario, 'cost_total_first') + } + + // Sum of the scenario's measures' MeasureSavingsAnalysis/AnnualSavingsCost; null when no measure reports savings + getTotalAnnualCostSavings(scenario: Scenario): number | null { + return this.sumMeasureField(scenario, 'annual_cost_savings') + } + + sumMeasureField(scenario: Scenario, field: string): number | null { + const values = (scenario?.measures ?? []).map((measure) => measure[field]).filter((value): value is number => typeof value === 'number') + if (!values.length) return null + return values.reduce((sum, value) => sum + value, 0) + } + + formatCurrency = ({ value }: { value: unknown }): string => { + if (typeof value !== 'number') return this._transloco.translate('N/A') + return value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) + } + + formatNumber = ({ value }: { value: unknown }): string => { + if (typeof value !== 'number') return this._transloco.translate('N/A') + return value.toLocaleString('en-US', { maximumFractionDigits: 1 }) + } + + formatBoolean = ({ value }: { value: unknown }): string => { + if (typeof value !== 'boolean') return this._transloco.translate('N/A') + return this._transloco.translate(value ? 'Yes' : 'No') + } + + // Counts of each implementation status in the scenario, so they can be shown as colored chips + getMeasureStatuses(scenario: Scenario): MeasureStatusCount[] { + const counts = new Map() + for (const measure of scenario?.measures ?? []) { + const status = measure.implementation_status || 'Unknown' + counts.set(status, (counts.get(status) ?? 0) + 1) + } + return [...counts.entries()].map(([status, count]) => ({ + status, + label: this._transloco.translate(status), + count, + classes: MEASURE_STATUS_CLASSES[status] ?? MEASURE_STATUS_DEFAULT_CLASSES, + })) } - actionRenderer = () => { - return 'clear' + // Renders the measure's status as the same colored chip used in the scenario header rollup + statusRenderer = ({ value }: { value: unknown }): string => { + if (typeof value !== 'string' || !value) return '' + return statusChipHtml(value, this._transloco.translate(value)) } deleteScenario(id: number, name: string): void { - if (confirm(`Are you sure you want to delete scenario "${name}"?`)) { + const message = this._transloco.translate('Are you sure you want to delete scenario "{{scenarioName}}"?', { scenarioName: name }) + if (confirm(message)) { this._scenarioService.deleteScenario(this.orgId, this.viewId, id).subscribe(() => { this.refreshView.emit(null) }) @@ -91,20 +211,32 @@ export class ScenariosGridComponent implements OnChanges { } setGrid() { + this.rowDataEntries = [] for (const history of this.view.history) { const date = new Date(history.date_edited).toLocaleString('en-US', {}) - const entry = { date, rawDate: history.date_edited, rowData: history.state.scenarios } - if (entry.rowData?.length) this.rowDataEntries.push(entry) + const entry = { date, rawDate: history.date_edited, rowData: this.getScenariosWithMeasures(history.state.scenarios) } + if (entry.rowData.length) this.rowDataEntries.push(entry) } this.rowDataEntries.sort((a, b) => b.rawDate - a.rawDate) } - onGridReady(agGrid: GridReadyEvent) { - this.gridApi = agGrid.api - this.gridApi.sizeColumnsToFit() + // Only PackageOfMeasures scenarios are meaningful here; scenario type isn't stored, so measures are the proxy + getScenariosWithMeasures(scenarios: Scenario[]): Scenario[] { + return (scenarios ?? []).filter((scenario) => scenario.measures?.length) + } + + getMeasuresHeight(measures: Measure[]): number { + // Extra room for the horizontal scrollbar the wider column set introduces + return Math.min((measures?.length ?? 0) * 42 + 52, 300) + 16 } - getMeasuresHeight(measures: Record[]): number { - return Math.min((measures?.length ?? 0) * 42 + 52, 300) + // ag-grid drops cell renderer output (the status chip) for cells it creates before the grid has a + // size, leaving them blank until something forces a re-render. These panels start collapsed, so + // every grid here hits that; refreshing once the panel is open fills the cells in. + private _refreshGridCells(scenarioId: number): void { + setTimeout(() => { + const api = this._gridApis.get(scenarioId) + if (api && !api.isDestroyed()) api.refreshCells({ force: true }) + }) } } From d4d7d83d10184f37f5dedcf118446ff027aac5ef Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:51:57 -0600 Subject: [PATCH 2/8] fix units and roll ups for measure savings --- public/i18n/en_US.json | 3 + public/i18n/es.json | 3 + public/i18n/fr_CA.json | 3 + src/@seed/api/scenario/scenario.types.ts | 10 +++- .../detail/grid/scenarios-grid.component.html | 6 +- .../detail/grid/scenarios-grid.component.ts | 55 ++++++++++++++----- 6 files changed, 61 insertions(+), 19 deletions(-) diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index b440c9e5..e184990b 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -623,6 +623,7 @@ "Electric - Wind": "Electric - Wind", "Electric EUI Field": "Electric EUI Field", "Electric Energy Usage Column": "Electric Energy Usage Column", + "Electricity Savings (kBtu)": "Electricity Savings (kBtu)", "Email": "Email", "Email Address": "Email Address", "Email Address:": "Email Address:", @@ -1103,6 +1104,7 @@ "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", + "Natural Gas Savings (kBtu)": "Natural Gas Savings (kBtu)", "New Analysis": "New Analysis", "New Build or Acquisition": "New Build or Acquisition", "New Column": "New Column", @@ -1247,6 +1249,7 @@ "Password reset on SEED": "Password reset on SEED", "Password set": "Password set", "Password set unsuccessful": "Password set unsuccessful", + "Peak Electricity Reduction (kW)": "Peak Electricity Reduction (kW)", "Percentage (%)": "Percentage (%)", "Performance Data": "Performance Data", "Permanently delete extra_data columns and all associated data": "Permanently delete extra_data columns and all associated data", diff --git a/public/i18n/es.json b/public/i18n/es.json index f0f7f5f2..302c9a95 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -623,6 +623,7 @@ "Electric - Wind": "Eléctrico - Eólico", "Electric EUI Field": "Campo eléctrico EUI", "Electric Energy Usage Column": "Columna de uso de energía eléctrica", + "Electricity Savings (kBtu)": "Ahorro de electricidad (kBtu)", "Email": "Correo electrónico", "Email Address": "Dirección de correo electrónico", "Email Address:": "Dirección de correo electrónico:", @@ -1103,6 +1104,7 @@ "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", + "Natural Gas Savings (kBtu)": "Ahorro de gas natural (kBtu)", "New Analysis": "Nuevo análisis", "New Build or Acquisition": "Nueva construcción o adquisición", "New Column": "Nueva columna", @@ -1247,6 +1249,7 @@ "Password reset on SEED": "Restablecer contraseña en SEED", "Password set": "Contraseña", "Password set unsuccessful": "Contraseña incorrecta", + "Peak Electricity Reduction (kW)": "Reducción del consumo máximo de electricidad (kW)", "Percentage (%)": "Porcentaje (%)", "Performance Data": "Datos de rendimiento", "Permanently delete extra_data columns and all associated data": "Eliminar permanentemente las columnas extra_data y todos los datos asociados", diff --git a/public/i18n/fr_CA.json b/public/i18n/fr_CA.json index 209e948a..9145fcd5 100644 --- a/public/i18n/fr_CA.json +++ b/public/i18n/fr_CA.json @@ -623,6 +623,7 @@ "Electric - Wind": "Électrique - Éolienne", "Electric EUI Field": "Champ de l'UEI électrique", "Electric Energy Usage Column": "Colonne de consommation d'énergie électrique", + "Electricity Savings (kBtu)": "Économies d'électricité (kBtu)", "Email": "Email", "Email Address": "adresse e-mail", "Email Address:": "Adresse e-mail:", @@ -1103,6 +1104,7 @@ "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", + "Natural Gas Savings (kBtu)": "Économies de gaz naturel (kBtu)", "New Analysis": "Nouvelle analyse", "New Build or Acquisition": "Nouvelle construction ou acquisition", "New Column": "Nouvelle colonne", @@ -1247,6 +1249,7 @@ "Password reset on SEED": "Réinitialisation du mot de passe sur SEED", "Password set": "Mot de passe défini", "Password set unsuccessful": "Mot de passe pas défini", + "Peak Electricity Reduction (kW)": "Réduction de la consommation électrique de pointe (kW)", "Percentage (%)": "Pourcentage (%)", "Performance Data": "Données de performance", "Permanently delete extra_data columns and all associated data": "Supprimer définitivement les colonnes extra_data et toutes les données associées", diff --git a/src/@seed/api/scenario/scenario.types.ts b/src/@seed/api/scenario/scenario.types.ts index cecd798e..a3da833c 100644 --- a/src/@seed/api/scenario/scenario.types.ts +++ b/src/@seed/api/scenario/scenario.types.ts @@ -2,6 +2,9 @@ export type Measure = { [key: string]: unknown; annual_cost_savings: number | null; + annual_electricity_savings: number | null; + annual_natural_gas_savings: number | null; + annual_peak_electricity_reduction: number | null; application_scale: string; category: string; category_affected: string; @@ -25,9 +28,10 @@ export type Measure = { export type Scenario = { [key: string]: unknown; - annual_electricity_savings: number; - annual_natural_gas_savings: number; - annual_peak_electricity_reduction: number; + annual_cost_savings: number | null; + annual_electricity_savings: number | null; + annual_natural_gas_savings: number | null; + annual_peak_electricity_reduction: number | null; id: number; measures: Measure[]; name: string; diff --git a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html index ba831c1e..34851f14 100644 --- a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html +++ b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.html @@ -20,17 +20,17 @@ - {{ (scenario.annual_electricity_savings | number: '1.0-0') ?? t('N/A') }} + {{ (getElectricitySavings(scenario) | number: '1.0-0') ?? t('N/A') }} {{ t('kBtu') }} - {{ (scenario.annual_peak_electricity_reduction | number: '1.0-0') ?? t('N/A') }} + {{ (getPeakElectricityReduction(scenario) | number: '1.0-0') ?? t('N/A') }} {{ t('kW') }} - {{ (scenario.annual_natural_gas_savings | number: '1.0-0') ?? t('N/A') }} + {{ (getNaturalGasSavings(scenario) | number: '1.0-0') ?? t('N/A') }} {{ t('kBtu') }} diff --git a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts index 9ad2ec27..4a6fe4e4 100644 --- a/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts +++ b/src/app/modules/inventory-detail/detail/grid/scenarios-grid.component.ts @@ -99,6 +99,14 @@ export class ScenariosGridComponent implements OnChanges, OnDestroy { type: 'numericColumn', valueFormatter: (params) => this.formatCurrency(params), }) + const numericColumn = (field: string, headerName: string, minWidth: number): ColDef => ({ + field, + headerName, + flex: 1, + minWidth, + type: 'numericColumn', + valueFormatter: (params) => this.formatNumber(params), + }) // Order mirrors the legacy property detail measure table so the two UIs stay comparable this.measureColumnDefs = [ { field: 'category', headerName: t('Category'), flex: 1, minWidth: 130 }, @@ -126,16 +134,12 @@ export class ScenariosGridComponent implements OnChanges, OnDestroy { currencyColumn('cost_residual_value', t('Residual Value'), 150), currencyColumn('cost_total_first', t('First Cost'), 130), currencyColumn('annual_cost_savings', t('Annual Cost Savings'), 180), + numericColumn('annual_electricity_savings', t('Electricity Savings (kBtu)'), 200), + numericColumn('annual_peak_electricity_reduction', t('Peak Electricity Reduction (kW)'), 230), + numericColumn('annual_natural_gas_savings', t('Natural Gas Savings (kBtu)'), 210), currencyColumn('cost_capital_replacement', t('Capital Replacement Cost'), 200), { field: 'description', headerName: t('Description'), flex: 2, minWidth: 200 }, - { - field: 'useful_life', - headerName: t('Useful Life (years)'), - flex: 1, - minWidth: 160, - type: 'numericColumn', - valueFormatter: (params) => this.formatNumber(params), - }, + numericColumn('useful_life', t('Useful Life (years)'), 160), ] } @@ -154,9 +158,31 @@ export class ScenariosGridComponent implements OnChanges, OnDestroy { return this.sumMeasureField(scenario, 'cost_total_first') } - // Sum of the scenario's measures' MeasureSavingsAnalysis/AnnualSavingsCost; null when no measure reports savings + // BuildingSync reports annual cost savings on the package and/or on each measure getTotalAnnualCostSavings(scenario: Scenario): number | null { - return this.sumMeasureField(scenario, 'annual_cost_savings') + return this.getScenarioOrMeasureTotal(scenario, 'annual_cost_savings') + } + + getElectricitySavings(scenario: Scenario): number | null { + return this.getScenarioOrMeasureTotal(scenario, 'annual_electricity_savings') + } + + getPeakElectricityReduction(scenario: Scenario): number | null { + return this.getScenarioOrMeasureTotal(scenario, 'annual_peak_electricity_reduction') + } + + getNaturalGasSavings(scenario: Scenario): number | null { + return this.getScenarioOrMeasureTotal(scenario, 'annual_natural_gas_savings') + } + + /** + * Prefer the package-level (scenario) value when BuildingSync provides one, otherwise roll up + * the scenario's measures. Audit Template files often report savings at only one of the two levels. + */ + getScenarioOrMeasureTotal(scenario: Scenario, field: string): number | null { + const scenarioValue = scenario?.[field] + if (typeof scenarioValue === 'number') return scenarioValue + return this.sumMeasureField(scenario, field) } sumMeasureField(scenario: Scenario, field: string): number | null { @@ -212,9 +238,12 @@ export class ScenariosGridComponent implements OnChanges, OnDestroy { setGrid() { this.rowDataEntries = [] - for (const history of this.view.history) { - const date = new Date(history.date_edited).toLocaleString('en-US', {}) - const entry = { date, rawDate: history.date_edited, rowData: this.getScenariosWithMeasures(history.state.scenarios) } + // The API returns the most recently imported state as `state`; `history` only holds *prior* + // states, so looking at history alone hides the newest scenarios entirely. + const states = [{ date_edited: this.view.date_edited, state: this.view.state }, ...this.view.history] + for (const { date_edited, state } of states) { + const date = new Date(date_edited).toLocaleString('en-US', {}) + const entry = { date, rawDate: date_edited, rowData: this.getScenariosWithMeasures(state.scenarios) } if (entry.rowData.length) this.rowDataEntries.push(entry) } this.rowDataEntries.sort((a, b) => b.rawDate - a.rawDate) From 09eb355bf2fabfdd78239fa6154d8e720b64ece2 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:28:58 -0600 Subject: [PATCH 3/8] customize look of SEED with text and logo, implement dashboard page fully --- proxy.conf.mjs | 12 +++ public/images/kc_2.webp | Bin 0 -> 61340 bytes src/@seed/api/config/config.types.ts | 11 +++ src/app/app.config.ts | 21 ++++- src/app/app.routes.ts | 1 + src/app/core/navigation/navigation.service.ts | 4 +- .../layouts/landing/landing.component.html | 40 +++++++-- .../layouts/landing/landing.component.ts | 10 ++- .../layout/layouts/main/main.component.html | 19 ++++- .../layouts/main/main.component.spec.ts | 40 +++++++++ src/app/layout/layouts/main/main.component.ts | 16 +++- .../auth/sign-in/sign-in.component.html | 10 +-- .../auth/sign-up/sign-up.component.html | 10 +-- .../modules/datasets/datasets.component.ts | 12 ++- .../portfolio-summary.component.html | 4 +- .../portfolio-summary.component.spec.ts | 17 +++- .../portfolio-summary.component.ts | 15 ++-- .../detail/header.component.ts | 10 ++- .../modal/more-actions-modal.component.ts | 9 ++- .../actions/analysis-run-modal.component.ts | 6 +- src/app/modules/main/home/home.component.html | 76 +++++++++++++++++- src/app/modules/main/home/home.component.ts | 29 ++++++- .../settings/api-keys/api-keys.component.html | 50 ++++++------ .../settings/api-keys/api-keys.component.ts | 11 ++- .../settings/settings.component.ts | 16 +++- src/index.html | 2 +- 26 files changed, 370 insertions(+), 81 deletions(-) create mode 100644 public/images/kc_2.webp create mode 100644 src/app/layout/layouts/main/main.component.spec.ts diff --git a/proxy.conf.mjs b/proxy.conf.mjs index 91fbeabf..61f849fe 100644 --- a/proxy.conf.mjs +++ b/proxy.conf.mjs @@ -28,4 +28,16 @@ export default { logLevel: 'debug', secure: false, }, + '/branding/': { + target: process.env.SEED_HOST ?? 'http://127.0.0.1:8000', + changeOrigin: true, + logLevel: 'debug', + secure: false, + }, + '/static/': { + target: process.env.SEED_HOST ?? 'http://127.0.0.1:8000', + changeOrigin: true, + logLevel: 'debug', + secure: false, + }, } diff --git a/public/images/kc_2.webp b/public/images/kc_2.webp new file mode 100644 index 0000000000000000000000000000000000000000..b7902856f04096e6e53477b0a39d068944c96cbe GIT binary patch literal 61340 zcmV(jK=!{{{o!>Dgg+R0X|VEkwzn;p`@bJsPK>q z31e;wjoe=S#-r(bUAVJoh8H{7*5>s~$KH2X>iSQ0|FQp{`O4(Ik24=J-X8l??qsDv zk2k!p`e=FC(zmvT%`f?9?hD@^+YiAHHu{J2&nEwV`UC%8g{D0s7r?2mS@_+k(!T*2%|LAY_7yp0x|Mvg?{y;zY`)+>#|L5uf z>EGWULO(kH2mR;e$Dco$eb@U>%&%z-`}_y`er10;{FD2S@?X4v8NbW$-{)WZexVMINWq5g-UU$p;t|EKW)5du%hwG7wie5tBkwuJdb4(A#&-g>-tgR|NFxOVPsyvj%4ll{NkG_rB# z{tCsX+Y52irUBwL$0bC8&$!#a(R2#Ggv7t+TbNnmYRW;N|0#zR#lzR-pBsDPQH`6 z%mbLg&-dNM1sdCAy*?3yupFEiHx9tPOR)+Hj9-al&g1$B-=C6TqdyVe!}3oL-=k7C zJ@l`$Sbc}rJ!w-DDsk`JM>5G+C67ZlWS%+HInkxCQm-IHPg z6a~z%gD!5~krVZQ{mMA5qq}f`0gMf*8B5bB{_UtA=`2ArXwmuJyNk#pec=c{|23cU z==Z?_Yfu&?c&R!uzVL%CBJ~!?8WZOM-FM^+(P|aF>E!M&%C`SLsFpl7mHowBA^5?^ zxRCGDrBpo}ifcF3)LowLM^T5Np=I%bSJZb%w>ruf?Z|?t=9As>9iz8wEXciSa$PZ! zsnKW!7}V|Qg+hk}ZxKx!I{2=rzCZmaa|SFK-v zATcJ;q`qH2DEkuvO{&DszI_JKR;L?Vtt=KGcy8d4+A4XRbY27tmi+d{-We{y zWtd{`F4%OHSiQWHfwg#<8gKEQ35}RSd#es$pwo&?N?HRAH4hAsvy4pmL#r4%@fX46 zN_`>|!PPkdy0K;kDvM^tcFba8+chG>5XK~O37w(4Y7X`f3=)bgn`?V#U?6c`e)7I1 z@b)H4KJwH%gYPtfZB0R*?dbGmB+);76O6@S>)&36{01g^}v|67FhjzYJwG8nQi$hBeJ%PF;i-* z)MJuIwMN0ERwu8y^|vKN$c~$6jQKbO+Nr*q3zCaE!Xw zZP6-g#KuU=(Om?8n%7HXrxq!^9o)u7F*2Z+aD7P=3Vf*PMsWW-kcU^4#d;T|FvSUi zrlPFy@8dCXshLoDv(WoiGQM^+=y&MNYpOrMzwQ1Y<~(hG81A>r;Ve_m%fCJ|WYJ|3^LVKoj?0t?+w7v#(^TLb@trgnE4 zVJQu6%;}Y;U~v6aMp2SOvYKNrZ+GJ-2wV4RuSUu!q9pz#vhcE7YYp@I9b%&(cmLk* zgJgIZ$D$)bE)6xo{|Q-z=w1Y7<lTLlr)^}*gfn3o zp=#O26?igm3l_M7ltlW`1QT*-&s>t0syi%6wX{BGe&auAaDU1`qVPDlvfu*y2xl&! zBp)?(ixI!YlhWzw!62SA#Ss^*o7d5oUM-fCttUHwQSgs-!wlEX}*RGRQUd109aRJr4vhn zcaZAf)pXJ3ysd3D+DUIbE8Q`>z(q&gIbXZCcymdq6Iya@wTMdBRW8$K>k)vb(}!t* zib&83X<8J%0rHG`2Vk6fY+HoG4Ey+b_BKX|m;?ShAQo-vrnMDKc67xm3O$qsdq zXyO-2>Cux_YO@_%iK^~Z=2>}nSU>*-U3M&y`ZsS^ssVV~sC1tV?jseXgzYLjX&oRt z)IN)(Z#noG6UYZX@)%dd*Irg#@($S8?ZIU9n8?mp(>Vt5pOf(r9fnKz4>cHzXKABrbUKBze6HhLMUe!Bb~FpLq}`@gBeW2vectrBkZn z&>_ADcsJ&WQ3NKsJ3EX7O67*i&?UuoCS6yGk>>J)>cE4`Co=dby3?XbtjK1bRmFnI zF!IKB0um;`@U&Gq_%{(~$QPE}c4K_qA`EhI7s*vkN^W#^eK~!i)5kSvPhl!SDcZqK ztpC?hGjrQ)}Rr^dWNxp35{tjq& z>F=Qe@+_zq25WL)6d`I=G>}XXygfSQH|_=w7vy)lgfnd%;3+ZI_=p;&SD4cOSzk$=c52K#bm7PDLK;OUl;Gg1)hlaO)& z#B*qv?JbRJjb0Sc_)fI|cOH{S2HvwJzcHNJ446l1KocXC_p-eD+RH`xpF2$x7C_Qs z82q3pr3)ZDGkEmp)6Kvn=#_J#RZp*)*~!#<`FDK}g1@nkM5(XGhf#gL+1uOqNta2( zYG}Lr?(G@+Nm!_7of@jca@L!gF2dBqUMFdP3S?JG`LKGkV&{L2nU+)PpyCq=3bxRh zp76ziR%B50vv{+Da7)Ssd8`1UmX|EwSUXh|>!jTg0KQQ5t46$vB9a0#jtq>n;eYMN z2-R}6tpovTcZ)2YINu8}_w^&5#^gP@-uvFJ zT7&?Q*Ft&YLa&Pe(*06c4Fxz1+Gz;Zu4#BRfA5ZkHwVM1$-Gr>SWJu~t zqInMVRL}1K0t?T{tCaO}sIw7Xs~Ev-W(JW=y}NiQaZ(uQvP3LMC|PQ;PdDEP6@QZK zI&CEg8!T*L4z(k}&)=Gke%b3G=iq zA~^k_S&Jn(~qyOCjADu#Xvvn1F`3($V*Pat# z#ml7&CEb41K~-|LP4HQR7sYtb=cIP1h`JA>Gs}`?nzIFDSN~Q*N%b%lD)RDM2z`P| z`rO}#2S8C>z+%|SShWuqMrkKnRaVR%amyA4A9Ej#!-}jwYA?%fq_mADWbRCsfx1y! zv|o5^0Slje>(@lswuM=kCZVl17l&O^5AUSIKocK?z^wcGtlgIwo%MDuSL;o2QySv{ zAS?=0NiCMPOs&SR+pFX~Ja$!c}mVK_v4=xRX;?9$?9P`wMf$b6eKr~46vK}(<@kn)93ow1mUWyrt?bJCNb*-)*1CfIFNwlmgC4G0iRHUo@{ z5~lOZ75f3BxE55cgQ8BYM0D9l7MoY<;>5Q6r94FxB8tl;m-MRd@_cr%GF?%6y(0WDadV?GmDh z8vgg}oRP^(_aeC~k=FMj5y*28q2?YdD{2Re5tzqBzmyZvN4>*AKRQU``MApDOqO`r z;e%0|?;*jTmFJfBIT62N3s{~Bq?k-BW!6@m`@krO_y;Lihe5Zc-*o*Fnl-N;Vv~~_ zDrFkASfDu}A$Qhe_8ZjyVUC4;ue%d{j8ci4%I*N=x&pPQk7*}tvtiQAV)qsCLhX6@%X6$#PtKz80KIC z6xV$RdPYU;*&lo@THND~v)HAmHJ~ZOpUxRl2IwY^E#VvY0;um13~%cuW# zUBF?b7T(M;v?4tyYim6;>mgLKI3*zCCS_~&#aqH>d$w1@#}`ReaLtPH|nC zD^h2Tm3+jRzRd-%w!_#&%5{rWhP0$45pTuBIfPVD$u)9w+PJ9^Vf?$@F zU-)L!#TLhm>I9N051;^JdjW$qS>GI+d&+67YP6Eu+kxigwj(+pcm)WG__3I<;fSm8 zU3a`Y7*D46r_=0eL)zoEav`V1MFl7yWLq?Imt>RkMb0`*qg9y8@4>^@?EtYUEj=q7 z4H41`Yt<3xXZ+)XFbwHjHULwMCW)(1^AckWJ>LadNs}ItZ~-+@Gx7SQv8EJ7I1zdc zh$&qUk!+J8PJ9Z)Bv-t!Bcf|Vv?l7W+g!6&52f8RrCt~DQS#@P|7%|^4w{q(VA7>T zqS)r2i%Ibw4n%inFA{K5qP5fxR#HWS$UBbe?D2$)3DauD-1|RX?o?HO2Y?{pSg4mZ z6Iw3^_oow!B3;;47IpW8P3?If<1ocdQI*E?)57d}qOyrK5B98GZ8}t3qdBctKW9dx;O3XQi@m6RtVMSk~jWK!a zYF57+PMkA zhF2j4#msbI(UpfcRL62-73J|y05an=934X+XPj!6iygwF1xXe1jG)?emRBkigVpQV z)yYaWF>oQNL%0yvfj=-T$huc&-J#`0uNlTC0z-(Jh7EgtBi7JfDfLu-;&a zJlhk-|EMJrX}6j(%%lM!;X{E4xhF~mq>5NR_NPID~Tjl3MP4~Z*w*s|1lPEo`97X zKGgDTxKCCrAT;h+O+JAX)xH;S;CYv9U(TkyO&59|GJ{2uC2|NpOYLz3gReKfDlWf0 zzz{wlPWbS&^CE0K555+deB>n(EaNA|8Av|~@@yXxQ;k$exq#K0;MZRy?Dl``zLRa5 zvA6BYhPnU>;ihk`$zg`m_`Lcm+5JT@hIJ2!`nG+85mA1JiCx)xkRP+WN-_GD7sxTS z_os`4y^dm`wLf2e>jmOf_az{-eF5!P1gW-9PoL(6l4r=6O%nu|PRm;Lk$SzY8ld2*GWm~a828rUPo^)7nEWNru(3>Up^XE2f$&LXOsi)yYiHv zg;N>nk~h31IWxuiGf64bZCv+CS!u1N4F6ob`m_{cE)B2PF7fuBHFWBl-bDzv%yBVY zuD4VWP@kAr^v%>Q`~YGxs|63}q%ypo=IXZO>}67%NQC>ip2He~e}BsGBB9L$reuFd zq)8zdBoLMpjD)b4Xm02_tGTGnlGHmH$<%7$fkewx$@Fw_E*_QV!d0V$AL}%k5 zu(O$6E3DoHijzxY6bb+C<|p_bVh+&*W;6tDzhmTJCPv8r?QV@rti);5g@x(zeNEDR z5@KX$O7|H_;Ijn(*^iG8>(U5;&y5MY3w%=HfB-M-tsmDqHNxkUu7(cMlm!Czg2^OO zPCzuvbhj62{+5LC&;A|e7_9EmxDH)zUt*|pjeF~Tx~ysYjOn*$dtnAkq2E(23ZN5Z zmEW#RwU!`Y?l#nhROvVR{(2RSAleHot$IK*7(#}p;cfgAIl`lG!OACRHyLmkTU=rZ zJ`_x4;OcJ)#dG)%8^^0UZu;S&{zS*+Q!z*?UV1p^OYzTDnUC+YfB^p4_Peb(`_PZm z{6wq&685jC8qfR+;BE*!i%>EeT4QO#=QhcRP{)xnZ0Nu+TG|OMkv;;lnX25-87#U{0?ZWoh`gBJR&P$+_DRX5W>R9vi5uYN3j`FQH9&V~ z4w;CjV37rapesE26-EGAfRmJTkvzfB$4D}^a`EMHJAa8!u>ioPAzNQfmx_|0SCD@9 znEHYXPW8q$+q9^k?QY7D)>Y3*yfwaZ%AP`*4&{J{K+k{k4hd^*u8Wl)4^}?7y)_+J zqDkQL<;A=$e=oD(Xee+xwO0Tn=wR2BWK!&(hAuPlwr|7N5esahv_9$APY!!<$xaB# z_y&&bw-=ySTSg9M9N#t>Xg%d=snj_#;fQ}X5W67nLb@&zOj2<{0dP`psBCg4(J4`y z%X>{fYM)JTBPU%d$R`3)z~M3=f2{YTP;rGakqNNaSNnB$%}@xy$J}ae7g0Vnbp*+e zqv6xB#};FCh+{SBEo%znFI6nMlvgn8_oyc7;b;Tbe`mY{;9ZX;mU)=*%T_XRty@}E zWZJ;y!?M3LIR6ZmRV68bG0?2VNzFI7Xug&Z3iN+ZhX;ICZE6+IvQc-IeG=_p65ts) zyL9JT7oysjzM);!$a$~73uy~FFmqTcjbdy^!f~D+)+DXnPq7Q__k0BohFRb7X@KK_ zOXhaKoV?n4Vn7#CFK;!c-J}%xa5yp%vVyM?D?Wd0dmyg>b8TcV-H35y~2q#s2D`F|m75{y8JvC!L`E`vEj#fH;EhXk(;@*9)D;m>b!<|ROegpC{}kAS0IN?bTL z6swx|iH>gUkIm|kMfcF&)D?{2Cm>yQ>cwgi=|v}12O=UXCY9!RzompMa(Lm3Bczcw zb+KQ{)_S#g&gJ*X5zi&(G+aPIz0)TaYs&OLJMO(kf1K(75_1ZdiO0p^-^1HyvyK8D z;733d^NV1`U#uDanQU^i{^OU`U807ZXYNuK+$u#M)O-YS|Jfh>wk))gK!2PFMDdDy zpXchCB8ug#&=Iqckfb+t z1@nf9sCWVKVgp-!2Y7uo`ZUu{D@MzvcWIbtOM8VpKma8+diXErVEw@RK`<+G&38I8 z(ondbStdv^XcS!G!`cbQSModEItOYQ+N&u%fn!mV82oi+Ak^v>F zC&JwANdDEi#-ZF|0Nc z9pR)Ycj^Fk!^n=he3y6&JJ8+`&hWiR&B>FgKF8IaV5vg=%wVdk4~0F+c%yhU)s|?6 zb8F%8-i^Gk-;!E#CB!_zv z?r=S(TmI8GeFr9uG0CMG`H3#Q%^mNwXpgF(;K{8ahifGOV2tlWv4qG4+FVI@_AR_i zi=LRMK)zh&v?K`3xh7OG`lQ2{1JotU@^G!cT74@D4-CEWZf#exz-Pi~D`dT?E#ex{ z?ud2?!T74RUAjp}b{6W5Q-VIm7`>h&7Zj*(tQUp+3=+iW*1&*-B>|3*j|7BzJ8&1D zfXR?B)uh@Aa;r&X#hA!69@EVWTbShE8I&z5Shvejm&2vjUOrODxumRXt=B6SaM2HqbKM{V7*Nk2bX zr^DZC!BCkflCp)^&&=mylKx-ip*m&uS8n{?ucrjNTJwJ%o~ck54BcmB4OrJEKgEWu z+-Tr$Q3yd=o{~1?0{r42zaOh=>m+;rjw^@PsjoAx>BuQ0rJn*<8NCgdNB}?3&#mi4X_Pe1WvGb%s>X0qxG@*h9m(TRj1fxi((Lcrni zw9Rb*>JO;w!S#m%@$p!wmb!f_6nlJpg$1YM@Ci+vNoMwa-zazQ784Gt~x>~l}W-cTjs zu4;SyaE}VWGSvpMDo6vvrGQ5Dn&&wBxdY+krv?kbN616Wmfdn0Rz^<`_7!xujM8GVLzp0nyaEjj_j1SjY+7I7k$|Yfnh4E1|MN} zJ`m}se+se$gMhZ$rZT&CA@Ys_U;L9FpUroF+ba(*eXrcTS2w3DD8yKa zdsNM5_Y$psrmq<(Du^KcN-8{vfgfjVFXN}GRC%NUO0t&6>=q>k-r)$o_;ZMRhca1j zO8?w22AdB*dW?)&5T(aXr3E{S7c_z*a1R$my!dU}Z+DtJ$S43dg3;7uT9ZCj;Gxh) z%@JltgkDOHd7cwQw=BadZM;dc7Hfw+$);(7De**M+E;|I4JKNVuEVQh^` zzs=w`BsBvmpm>3qnpz@w;Z<8M5K9B7f8EurXZpK6Ea$b_qMk^?fv>;b$~Fl@db39; zO%AB|Ba#vWt{oaxsoGjqZMV2Fq84C!qiW+}Wqam{3LULEghzy`A`PynFXJxM`(ry9 zi++di#O!>jvEN8aN%6YJGe(NIzGjrRrVy1%O7bT#Ug%BVU$&-lVBsm})>mg&3;B8C z$SqI711h3;$8N6IgnXG}7vT8^p=5Qazbl}QT06(1HAj_xoB%W;Xs z=3$4`A#SZXiJpdT!Z|f6ldISca7Dx2KF@#zd-YYi4UcId7g!;ZSIWv&03YvTif}VJ z3{uan8Xqq3YZK$(`V2wbyEzXL$2AfRw#gbwDSAk~|Du6H({{2`SGro+24Wk1A+h!v zF`aF;OluvTnz4vA*1lLG3kK43>EuR6HX|vL;%X2#(s`2WKFGdTE?{W^sgHxY+*c?r zP(a-_7(A!anEOpCqksCz+}+Dx6e6Eba~#yJM^Fw`T}?iZ`|cL=g_wn#mEpU`L7^w^ z^&2Rr9Dld>Tr$mbxcWac5`qsZl6YTbu3_>!1HDc!3RDN)6p2OgH*|s-hNW=T@S2C|ve8yVK2B-D`)&d}$*Gq;uYQsE z*GYJNO2URk!76(P$r?WNDIpYGH>aCnqTC93A<+Gq1Z{D+<#h=*eQ_IKfTnA_nd;Nk zuA)s&oqYH}M-Z@R&Ka@}D*0BBQ~_aY>CB43w5WWkqsL1VRbxDX2tN?Fl0qBU9t3C` zND*$q@b9FSi8z^*`jy8!*NA*AU^3s)h>UWfzbTNQI{EsmwB$60q1O&LF_`Lo!B;J{rLX1xC&7keI3q%6j;A4OcYHWslq%1T-l#!{a zdTNDe8TZ1f<57Mjh}Yq_?kBx_)Camw%XGsP;u6FlakGE9&C}uC|J~4mPR0-7dDen( z!#rJu=#W;ZIK(E0ygiO>Z`{@i8q`o0rW*?KT14tML`Z62c`Z5$oDeQQ*;y9jCjE`8 zNkowf31mfnJ5ie<=ezrcoQK?((kx zk+Y0;0`lyRDMhp4Eo>CF0rHTYKv$>Rk+Q>aw(F7%v83@bOJ;6OBgb)qcsLZ1U1+UE z&iY+mEZ3TD!tlsvgd=Fk*U={&=Qu56jhoe7`BiKuUct4j zaxbi;@rw_LssPG$7}axnDi?=rojGiFF9 z+hFHXFnfEtA=rx~da{K6BC2a(+JyBsN5U1%M?LA!Q>h;?Zu@Ophu`mTuCL9K4(0#wHv4m!(UAGJMt(8KNGw6Z zjVI|NXZ_ZNmxHAH+3k+gSoK}R+AAIi=nc)-8-}-(V@6tMPHl}!`TsCi!%j>DTfjTc^k;P!@_z#c2 z1{#`?(-H9(uJjPN$yb2^4+f&CH;d zFfbR6bC%stxePgG{zU5(H(`S3N+&gB8*-cr=}nQtIr*?Eho|&e6_y+nSTG?QBUNp5nB7-o5H4TE5Z$)VeZ zO-cy<@t?$04{^2O`aV?#ak}bg=qE>?k1mx&FUWuOSP%pzO0@;G2+UJfNqx@sKf=qrT?SA_qQPSsWrP_h;W3J2( ztL_hn5S|vMAoxNTu-|zjXnyro__M>z|C+gUG)>n$-Pt!g{~_Y8!)`4jdd0Y{3I$qh zwO*G2MRBL`zK6=r_B3ZMi)d8fkdPMy{8n1%u$(B1cFoMf)7+!Up}2n_x13ooU1k~Y z#)-KmKT*c+x5JIj#Q1K!I~&kAE2i^={ZRU+Z|dVK*kgesGM|x=}@HF)Zz1gN<41l|!VRh0us^x8+2-4JA`1xH}(fgM|8; z2nYuA7DldfMDyYJT$4y1rH5MSHB50XaZh(_uU!sjG7gp32;pVS=5&#?qC7aj8dbwo zs6DX%rQueP1H_2$#DT4o5yhZB1pFifBn) zhTfmvveslOje7&%V-(%8k{A&Q!~@ZaU|i^DCp>Wa3F!8WKc`uvv*)F4wGi*?yFIKm#DbyX#AfB z(Mtd?vO&)WM6DYvf7f0H#Uv!kQJZS-c_^3;+Ds_IOS+KibXoW=8)57Sbd@ zmS0o87YN`lu6YsHR3rSLyqkdlzpy687tAO|HdB0Hcj(B(@Av7|+8JEoJ1)eyA*3hb z;zI&I5gGLvLb3MdA6=1MPg?oj)Z}P8l^{{WT;|Vxr$9A#+>B+PM9>13!O){|GJ|5f z#(?dLibx=H=Da$*;2fTuE64qie4LfW#J>|(lN9{MK+w)x(Jma0ec(3Y;#n)*Ldl~&9?avV!*E~6y~((V{b&dNimn@Ks$qbzbx#A#o2*O$ML z)Fum$lEr;f$mDDsJ?trFtjR<8@L*CLi%iTx-;=u~d0mBSBc*E5!SYNnN;qM=3q5~n z;LS;H7*mF&+%w}SODW}vn|j`e&M5X^sU1a*3Aw6?fi!wY<9pWpDY>rd%%aqmC%{%V1VxEX zh0ftk{NnB)&;NXiR!ejW5lu5)%;Scsf#h30&r_X3$bhTv9(~q+xcIS&gL|MmWJTvY zxGptHWQqLpUss{vsl}?n7q{>tBpmQ`mAx}wqE*P5mfHiA{>=3lw2w_3Y)&HM)fa*#VDWt`_{V@S}t+7-9T78p_BUy;IBgHT{A8gNA$p#pSu{)eAt6@otu*=Fm=h18j zb!*K%UI87{HkLUCUX-d0rF#Xbe7Gn#2ZaPBKM-l@KZh#dV4}s{C$}54<}LwO5C{7Y z@N=_#H9`H6eUR)=%xC?WwAJzZxmj%WX;15Qg1Em@VJm9mLImwYuH zRQriC0gPKbh|C!~mDTThqtpQui8L6G_kl^~`o$2;1!n~CyCy1d zVpcc(0pADDccFj;PwVwwzdmYEJ5H`GzVD(ae3&>cEiZNt(c9EL?1n5@fg|BHINU+yXw z4P!E~k|!}38~^OSm+a&l_mVK+d#l(3tAQhE%0bTz(si|m+Pxz_`_H*ERX}{a3dj5M zC<*x*zS1V-!63B46!XhbPx^Wy4hJFl44Z%%kIj)sym*V-j50t9PkFgTJM5*QTQO~^ z5*|$O@ty=3A$;0WIlkc%w7_xSAT5Jd=5r%}9h4fn=#C1423moqY2=^>CApcGoj>zl z2~?PWT9fcT?n#qo8^MY_oAq~f0iy*5NUfss3FvI*#Pa3lJ|&->Xa^}~%B*Ntcr>`I z&{lHyz#jBvn7;EU04x$fvbrR$ndVvaE3re_?065HMzU9LDj`uMqPG3mO8P>kLo)LD zei#4656n*mO1?H=;c@n1HKgV*g=smbunlo|;;*%4Ik>*bU9{s_yt^!w@EK)Vf>{E50q2VDeF1dMQgCd|}^S|YDFjiz0ctWN^V za;@RTy7gY`>gZ4r-%$ro4?8M;pKd(aU)`>~0(Xj=O8sC0?zNTAroCOlE5Xh3yqsg> zmF>co)uk#9+1wQ|b&xxO^EU-(_W73>uq0mK>%myS@l}0`cBsgDEX6qGupH*1D0o^l zprr~-gBG_=c>txZ4ALZJv>VNh8pDpEUfCjg$|Jx^+f%Z1UXUxyUo`V7Fc8}s^w!OS zhh|I}KF(om9Pz{wErP+pH)~3>LY)A9p%GrSj=mA_r98_+QWHl3R1)tY|5FE24Pc|{ zOUwUR0V40tqzJkpb&hIdU7L};baNzaj&kBcE9QC!b&u+rRWY3wj3;U_1m4N*hs70_ z9gZD_RH4FG3drreCsNz$Jzo^IZb<3ZOi}+?mH{}x*_YKk+ET=*uG=RW;3!5zXAITl zU2kO5#e;scv`=qsH;X+Ocyy;2XdR4JO9Q=zoQc4ARtgbhTMawRVcA&4RW5cQe9L)S`{JKF5ukoPkG!rsa)O?xe=#A?5bMl8` zEtPl&vx;YcEPAE}n-u)aKLm>c3m8oS1oc#yO^I;yH=M3Zy&P>N`is)g>t<^h z)C3Jy#s?W5tocGacyegm=FRfnVd5bs^TY&?YTpqKmS~;E2xwtiQp_wEdn9nHhq}ddF(Q;Ul)Z`CJ9 zU+Q5u4heuewU|Inp{``%T1l481*eH_FxQJzeOT1s8+7wbf(o(G*vs>p=2PRr|dm}Q$ zk`LT}%*-k)hA908MH<;FmvJ%1SOdLH zHn(b`G8JO^uAl(vWZ)boq_NH5H5tipJc@MN$sES5E6veb{n8t$uj6c$EghAiu7tjV z6Qdi3z;aO_A7BOpnf7@R zMv%?u>SYFIPkdEPzGd*qg7i-$r*$}=GlPOik_Logoq6)FZ1+uu%beq`q~0t`0wG zt@EQ}h3d*7|JST~ifRE6qL3kkWMo&ze{;$~78iJK$&|mjV z&o+WgkwR_!sO$($$)-$h5MA&SCDnW|4wK!&Aw;bhW2T)KXfRw{_vNDLEpoyV0GfOG zB6E%bY!>&#C&VaAZIzqgcWQ~Sf%D1747c{VRDbmg(~GjHxsqy2FmuW1uSRrH)InJThGsreenmG$+)EO@mY_{d49j3n)$^B=puzNT#~1XFe4rQSY*V8G*W=k~{{L zH!_U*8-(+b`xCT;FFI^eR@l^jF8^(6by(xVFHuyG-lU~r(S3&vD^#FGRSnwR&$wg_ zl)Vke4?sO+(*@@2bILy?fIakJ5GqVd{2K!|cCIW+3sv!Txn)JDUQeAHWmGNiaO925GdVGk zxrj`HaDs`IcMSx0XUD5B1)hSYM^ZGX`diqryaCMiXjmJJM6{bND?GbZ2`|VNs8%_U zng^zx`C3QJx1F+rpqbd+0IcR$YM^Mw$A9+EZx>^t@*omqB;Zk>5@4G*@@g{EV(w$bX;QAX&HZgp5jpkSr3YA+ku^ zJ8cMPU5@_Mt9eXXq3l(X$vH&xZ5n3jJszBZjNaJnguJ%LhN}*?g#6@TCs6tNJ4eqW zvCvx2)N=y7u(>40F91)ugDKJh*JI+*^w2B_J|O5^?HSGks%>W02y!ly=x;tGJS+84+HjJo){N2WC*CFcSU^Ie#$A zTG0E7%FZjK0`UW7Fuh)- zydYzpD#F(t72P)q{!u&x=5&}q02x5$ztg=E>nU2Q2X$<7(Nqja(`M15V-oN8w+0IO zCf+qjU;5fOyUDf-s+(8!L*a6LGHj8}HQ^gyFtv;|0lOL8tPdQ_h6renD#fI1$B7ft zWQ3e=J+W=lD|=+GPz~~IQ(Af*jSxSyaF6>M*0@RCw>8cZ&f`Bud9T_VGI*+@BRT2- z>;b^-oriK(8-e94M&hkui=ZCFxr?U_7u1d7Dh(+!mu({MA7#v{_b{Un<*5T?J^h=D zL#wVX?@-+63$8FKqC3?i4Ph>i;uZ0IqE3r%^)qR4TT3&I!7+)T!oE8B!@jk{vzOn-_TB?P;YbDQxU6t44dKg@$q$=$MLWS^WU_jM6R4CJ%Rr8b=$SSU21 zF#~Ad;O)Ot1Y0R#_XM1UD7rH@Z5$@elLBS&Q zL^HAi=Uw?BM_18?%8ISgTG!TQ9kQ%yf6F>;AuY{-5?A#ada76zn-zLK9c6vdZbNmv z>GI0z4SDs8w8r7-!x!AC$^hZFjOHIp^~}#OXX=(vJ~qSq%@MXMcozYV-yKy7CAE1ZybmrW0_l*~OVPm{iC`I3 zQHT#Yo!|JJ#I&bsP|jyc!%e=zgQ^0IXPd?~QQ*;w*8wOH47tWlh%QXnj!rC}Gm-RM z0M0t-HLLr0jRY$3Xe_T9G>xuiCGc)Y$WSPo`BP^wn%DLgpN(=pM*@^@j5(<1@JaJ3)EI4pr??S&B;DH*EFm5C8qv4VXutL7ADlYfKII*sT z{r{PA#pl3~fIsbW>FXgA0nvp`CAN7D`?5j5(Q(V@Lgdn1TBIS1$_!`y(S2x-0?^?* z3~6}!gN){BFdzFebYvdJXqO)ptxY`infqoPmFP;u@>y}^Jn*GX2YY-b2XByu7DB7)ys1Is3Wg=K(BhlYR zkSQW5D~pPw-CL~|V>TU14H(6~g5xrj0$4z2&+`6WGp-8BaC_GqX9e)$qLd#f#;z+w znMlRx*TXK#Fjwc9w1S#S)`Y#3@KAxQSPNjodJFK#!mz=gcD}JHtdCyq{gtt`TtV)Y zgja`nw6#gpmI(5Yg~Z&X+}%}*I5gVy%!3b=2T{?8#8sc{(lw;S;*yg%by|F;Z*&q6 za}dnRfKt~xB!Y}kUWq`q9_rS4rpSQ`_4~?uVhJDTAB#)*+OC_CI3b?y$f~Ml_j}O_ zJ3d0ya$wQNb!72PA8YHS?}GF4^B#`cjl&R>#!oQUoCM4)oRAR;LtzGF6HWzxs>Brc z6|W0z>9=$h1rI#AWd3?7SC%{9<=<=psX6_L1D?xz;os@3gRi0t^K&wq%t+AV>p08VUy5u zl-Djj-FsO*Rl$&gT&Rw@e*5?Xa4k(DZW%FQZpYqbJ;LPb#Zu$ANl*2#cpm&t>j=z! z4-!!14Of>$!PcSGFOhq(>~UCBhp{XyMLdDGfpLtwgx4> z!zGf96*uLH>TY~UlWNZObiAOO#Qu9oM08HsWr3(0v+biuB(KZi*z>mN?|1MPxwwT2 zK@#)3hU|Y)=Ljun`M*BoQ^TE3m;bw=$i0PA4fG`RjCK(i?ssXI-c6-#Hy&yX_kx#nMoNGHN6h%Q z-2t4hpXg5(B^ZIJRF)NBVyYc4x0w$E#XbD^n&o)fv12kkAe=I`b;=pa{34bQ+kx9? z&xd+o^c#ve+e+xT4!;e@NExA*n}y8OO*>5jDq^aqA#?_r*(8d;EU1x%`8-s9XkI*& zQo#Wo+U|Q+AA;)&01uQAoL7hB&3XbI`H`dfU(l&yDnf5IOLozPgDurLMW65(;ow{g zq_QlQ0Jov)4EGO-O;pF5FuvF#r{DrWv<@)G^i;zo)aOH}&J$sW}Ds6H829 zQ>llH^B5bVav%Tsxh|clEs^lw*$XyYB?!0uhlaR5z}e+&5J&HsYEoIuXD$-GE7z_qktq>WJZkm#+aXc!a$99lS=@k!i^!NF z9MNP{de!1km-pfA6!7|yR7>IKUbQ9<`$619T}G78iSHh=16@=@H8_xV+cGLn=XU|A ztOL4vO+s&w*;rz4^>Kc=Sj3Fi%pk95gNH>M-DS+-nU_f zOT%mrD4pj|6AAF?5yFIa`hb5EI)d^FWFz9EIY^xqVfbBM=xV;S3n za70l}^JZQaNqkgviUxqUI2hw`r?J=L(tF7m^Y&ODuOGtVYeVFq>N8;hW@q0zM}pUn z$uew1%EB(BU!U3B1!mp}bs9#5X^QQWb0KFax4l7{W%f)+!a!~*EyQ1-kR?RJhM%|{ zS=cWlmJKy1aJ}MxSd9s{I{F>$tTRUFjdv;a>I11SF@+Q)Nq=_h)hbi4qNcp=Bb?`H z8CJ&h;WWn0?M~r|tnA8|ZbuoL9ZC@)HT%~XW{6DuoPOz@~E3Jn_h#yNz}9;(=lzbsLRLiOz0uM~uXA@o-Iu(i$w)lBHx=E>}K8 zwB`uL1bK^KhWjeaS^ zx^Um}8Y37^m~2V!U)Do->;KTZ_{+XfE|=~RVm1$%3x`Duhz(luRhjUMy);Nz3y((n zL&+7iHNJFavw$?=bN?dIXd*Vq`MXqSR`mZI`lhN9J+Uju%o~Tf6Q@%^DPB-@mG$wq z6TgToJLHNmKxnawVs|mx9Ho%Jxv4R~R%cCVRX_}Risddh`}C=G zBxy@A<(pnJjb0o+T5F;{6%9RANrPlWb*cepq1AghYdGso>OkCOBNKZ|+0KVLU?cp@ zUmfI`0ya~E-iZSdF3}tqJ;ly)#B~^qJH;h}@#!=Fd>k%!6f0{*#I5~JchsruPrvv> zeVa9P+L1o;Dd)teqh~P(+hy2$g`iAk08Z2WJMy+y$abXuFHXCQ?fGnb49Ol`tI6$S zpz-p`aa*P*G}X`ixk)utpMPR&0=%Rq!?IFuraB(?#ImU934aO;u##TX*tUHY56S!_ z>G_Ei2p7Nt{t&T!O=x1N*IDddQMlzSpM+i((%fi7$q&3aR!hGa;Jx)?J+=S>!QDjh z^btashRNXOpeD$A^#vy}x09LO+egHsb^fW}@L~6A0QcW9j23-l4}utA$5`V!9lB5? zic7@~@Oi%;8Q!@Wyf5L=JeSYnyR~Z+G)5fWCX>COr(Bg;Ye4n~vo5Vw&>`@i4Jg&g zqnx0UzHmTzc}&uY@h*(@PW8en593 zmpAV8!dpfR;>myiYV^zojO0*PlPXND-U0rL!o8Y#S(_Gz%Ul&SHwBHJPK#6z0Yfi< z1{GLBR_Q7}b{Sv_X}EO3PbBy=c=LS2mE6*>lNZw3Y|q5{&C4o`XSPb}$;J-BjP~lV zk-xQB$KyH1Kh14^h#~w77^WqPQg);|=Rzj8ahNJeiDtQ=u>-LM?I8+i*M2*Z_BIPH zSdx0SmNoaE$~w|x%x)iJHYvTD8(`NpQyq+NUG7eBA~bu8K$d=(21=MGoH#HnJe7tn zw7U{Eg2QVz@Kc$COwkw)BZLlBp%AbY30UTzqNZTdQSaKOfFY=HIYVpC~DXZ${Qt<>9*6HRB6}6oU6nL?1*0V zBN>)Lc2s*!lCNzd#k)sgo$pb+d=`tdciP^&Mf=4Aq(^H%E1o2nT$j81Os(voNZxd{ z@f0B#1V>p;c7(1Tp|mm+qLGUA;t$@tc+z~N?zL>(_-+06@_@i@F}(RH(0r6ZDSDfmQ%Q&Y}S7u{>4BsD!R3(ns! zgqZVK-2|BQY`q^hG5cZr?}JwJOmhP@*Lp2?TOt4Dfq*Q^a>1fpJZhuR?Oz;EnX#a5 zUX@#VOWyygt)kXPhEQoAx<;Tr(kknV^h+K^lOb;MUjieG-Bf3(3o6kW|NCwwfT*M6 zoy-#2*#RLIBF7hVv+af@BYq8=G{|lqMU9?t(m2v$38Y#sZ$;%*SOXNExoGFND%KDT zW&>Qj(!HfS^4MbJ&9G1aa56y(e{{{ahC}P@oHqA1d*Gxf#DZ&o6keOO8@bHbjjMi4 z_8Dn}2+-QMPZ#^c&YK~&z98e)q6q`nLn~^B;A)@@q5`6&A;i<)0HymOHk5KX3aU;B zqylp6`j0S$Y-;FW(hCC5aaOUf0yUT4X>swI9?2Qb2zE7XwA3#MXXCd2Al(y(H3l%OP54hWXyS1PrM{j1u zgHn=}@V?ys342FT*B9!#IWp%?`XSE*vqh>3J3-B7`gVSETJTLqJF9mRNlFMy0*$Ss7&G!XC zeF8KM^=!g-3~k zuq!R=I@blgIg2T|i8s7dSUuO2!cde9iAS%UzIV(oWc8QqXL%N(wH}LVIV6Ta=aq8} zNK7;Ri!%YCDJbpXSnC+>A|lFyT58yaefX87O{ZV=3@7Hpns!g}e3G(Ks~pjoekHoW zRcE~`eD_*`cDC0Kzz*=ka}r-8`Uum#?5;ofieUE+d^0$pvG224lZ-E(vD}#klq)d$ zB^A)SGs1K9iL(kT$L;DmX)cd}U45z~BM>!i4XqkYc3$`b;M04vPmME`@o>0d!ly|D zhfujs6==l)(@aq&hN7x(e&y&ow#udrQqU9*RfPsVxc?-Z*@IE9w)c^{TH4J=y2#@e z;CwZ9q88FM55{f(A_a@G$m;fvO*?k9SDJCtkw`VLdoHd_4K%%q2+-+k>_-EZCHKJx;*j6G z_vqPlvH0qi*nm-Ws$J*u8SU$|&b08ynB>y`En|Pv&A8@fJsoM&52Gjp$wjs^&jgHE zXY;Fm{s~mMRaW~8af_>VK7nrnOx{rX0eeE#RTm|g?dR^vt-=`-!{9sbB@TB}aXB8p zTO&W%z=oTcten4D(i*9w9G4j;bl3 zAC_BtK9#{XP^XEE52H7f`C$+G`x(v2%6Jv2OlpXv0X)?UFOGCOZAT-mlotpJKG&N) z!Of1aYfa6^h2hIV8}mwLVd@7nz50O)+f2F0$R@ zIS%nVZY$H0e6Mg)8ij&>Q%Sjb%B$t?Q`=~7DU0i2yTP*0>9jDzbAw_Wp_VG)NcNc zLG(>~M#+9t9uGJ1O;0xF&TwK_q!~4$7+)ZDG--n=MSr>#9h&pQdrz8JBW+D|)+S<> zst@%cU7jk#)Sw7?+&Ii1xP+DbcfX@X1pXIL%tJEytm}=R;y+tg9*{%Ed06P^4+2l@ zJv;=wOU>)?eQn(P*FO>0#8H7iVoKR%^_rIY!SYM8mzw|#rJ~g@=(KcXXdh2(%*hq?t z+$h7XAmm@K1$d);t-`=c-qkl`a`qC_FyIvNnPs!m0dg(YDed45OU3K8u;VbH{ddx` z5Lk@sq&*H375MXT$X7U+lJ&iP0(^wMzOa4P5w%+g^R}ogWX${kB^$xVy>%I=bcfif z%z8Tm<99C7SNIP>U^YcbsyT1laKAmv&yu(@cMsNgT4{}=2H>0WFoqAUT#7xE{5ubU zZ>c{--7L4A902jX^qyak3GkTRLgCZL;M#l8e{NY5nH>$*s z%%_e1;93;#@u=zJ;Im3%XSvff1`auvYfk?CL{C@285r%o3Kaq>uQH()9#DMu+++;) z*1Ht($W5QIw7jcvLvz_}rFN!@vf8|>f{v%B7Jf8s=F)Zwj0Jd&@h(7WQo6C_?W!Ud z2SSIgFE}_PTOn0^zC1Ip@Si5qQ}LZM(*hv5aJVJ71y-O*N}nPTq9F;IJRHD+Jw&Dzu~_r zfg^Mt+uM1AvVa4Y-P!BFRA+f^tp!NS5ly=kwSKgjRW+e_e~&7E-0Qz2nw! znoMG3(B7Vh1JEo5p;c)sj?rl3cKs}ePXU3F1gl-bMgK<&u5gQvWIs9Ar?BBi*zLzg zbmyz@JY%|brvotzSM+z5;$E{3c>K$#PrrmDK{;t&=>TXh zsR{^w_^%(vAtG!T1@u$>XrGqEZEVX6P~@@ZSY)=%cof#F12;R{{8ODDAG!%c?fGX0 z5h^}U34pg738gSHzoEQ(v#%X=0I!KFjanWqh3GH{I%cj%yY~U-{RpvZ5+%}jpG;ad zs2v9YY0Z%(ZG3R29t75L7}a?O&5{yIqgq0tcVQUI5cr{hajv(%=1vavCb^O+jz#P$ zh>{L`4JsdfHOFF(nydq5y1q;#|dD)8xv_zyJp)On+bJ((cPWY<1iKwIl zp*DHis#N38svy}~rT{J56DN>I(?i4MH#r`2;~&=DKNXy^K&d&UnNA&Wd0NbXaUFqf zaE`{IufE#a$7C0jt7$#KZ_YxF-aea-z#nT$h}BD+bn(9r^_LPD_~OUzy=PwW&8O=# zIHCbDa*c7=9m5Imi~aWCj#@54=+WJif;m@8<=8!I_+_AX5xKT^=V=C!bBgoXZ7b<} z>+0cn24?kL={2X$t;sSpT?h3J9E9=LiWl(^RQy|rUssx+1e#tq$g;P8)x>R-*} zeu|P%cJ6WBqMf)_;8TZx17HYlf`G|3gxXFF+^C|KIzN+?S-y0`=UDd1bnlcU@TRakBWL z)*!Rz@F0|<3+%Cgcu4?GA=D{ps?X2ajOjU6(@;O*gh#tp)k^^-4TQCKJg3p96~-h8 z;l}yhq@H31SJ^O?-~3rSc_NZYe9M<2F#3vW#{`P%9|8(jGQfCl?!V|8Gv}vwGaE=+ zk=aBhZRQL4IXsbA(X%<^hskvgnedv#Yh^rQrTW6$- z$sAx}bnYZJrtID7U~7k=0y*NQt)&xCo%&NBAx57;X`%6+bUaMG4LGCnb?wFcEO~AL zez3N_GGRgprH>F^lTBo3vZT~e>J|(SDzupDgnJnRlu~IQI!u($X)F9v`}T!rhjT8= zEN+4Q{zV;mwwDhc+y4Yjp^qds<-}Wt{!zhO0l5QoI+Auna-u#UCYmUwes8#aa9NQ< zPo)qj>_MfE2Ja;8XVhWMT1o%KNm#d^lUqyz8eEJuPmvm_uMVEy!5TaykS4+&!meY# zI83Ro*vsp1`V4zxr>Ne&j-WzRtg&O^pIAzdv-hnV z+!Yb?kK=ez5#z2Z8v@GiwIjJV96)e&o;(E9TA6jC%mrG%B%g7S%ni)ux2~1zSl3 zo*6q@m$6Yaqh>*G+MR^&Oo=Lma`re(RvC~n8C5i{(TFR_z=vYz7o35%m$o{JscFWn zRkzF`ksVhB3LIGr(P$*2HX-U~Rf$eqQ+F#6Jp&U1J1%4gu*{2-SMc+7XOB47tLyI~ zB5f!g%$_IGBP;#1)P1S$s;PDUQR!AGO>1a&<7p2epw)m9I8hKFqNIIM6LkQg+{NP0 zXbc_un4RO$%meXd}A0+S1#*Dk*${*9c#ALERN z;q+B78qx8}mno%?5V6?7Zj_G3PL^vN5VDLnhgojay!^aDBbCv4cO;m(N-9POg#q^% zQ#y?r*$##94otP|sh0xXKrG4o8(2(TRwU}VzRTUyxX5jX-S+C$OpiTN75!K3Rgh-T@5%u9xs0f11pZ{CC)1Xgo_GBa(4 zhL=#%E4v+;@)`J3MB~Cd$*HMg@+yz6tk?xp4S4z9GxEHrM_pRuNP{i?dwk(fT;|DuqZ{#K(ezL|3u}>a6J~Z>IEw#Zy{12jyQCX# zk-aGs!v5c`ng1If@%>09mP!LphrFUrhvc~rB@F~hJSzyDZ8-k)R$8K=dYNeY7_?uD zJi}P*Yimsp9a3}|Ru@h9nn@<lk!fzJ6IiR2UdllGcJ3_t~QiLh=m zhjVbL1)HQ(Qcr5u-QdoPFY$dXi(%~Knb{m}0^CEa5{Avt2?D4tzIwU*u%+Ws$+1U$ zH@n=rKkN(YL{1a21H1sgzrzk${c?tDmAPX-O3!dqq3vGq+VPMZno`4k+Ek<$Q|S8D zLj@@qTKTUo_LS~_C|bGt_cn19pABM~76J05M|f0NLR?f?=a#?JS913?^tkem1GsKx zo_vRdEcMkd?j&zs>?XimKCZLRv1g}0=Fc7vlX9{e!dMpUJ~y=$?ew+jj)>mBsUIg4^}5i^Cs(hpS=fJ90F?PPZN%J#i$! z`{qxq2VmGmPs0ag3bg>0f6)AiUJ6*I4~Zhu54Uer@^`CQRFpR!LjoZfUDD!`^VwU< z;uLp@^&?>~nDd^d;25;w2zwF;=(Th=C0;Ocwpv{hs}0zvWfH=2OnP(YNV^O$3!%;@ z7~e}*u~m-8{l~Dmc&O8cBM*H`ZJmWpbwAJ~qU_c;!#_xH`fLkda%R}4@GVqTHHBd4j z*db}JDrYEq#k=hf@4D1E#Q9#->_KyW(1eMk;|>wb)-k*^*#~!fy`*9p{u@B&-Dab* zpDVf>?Ste5rur4N5JNF1n)BTW`cY5U@9)vEdgoedJUCd&MDIKD2}7B$&R><2lP+EK z$l?(2K?Qgyo1Lyx<3cE9Z>F7jp?}HcJZk}VSY;%$mNR)Q?}@2@iQyt-QEjENVj}7q zi%3f&<39@vI+k4t{m~F776GiLW%P-84TvveETf< zZn4yy2a{69cV2Ik|9*PYeCeiNr87~ke}(qY0GVZDBQtao1@y0-md!r$M^a(S!-H6j zZuiQojE1IK$O?S0Zow?YOJZ2nm!YB*@`0ZXby|vwDM~z*)>%gKAUF6Sq(T=){2eiT z#FI7w#c#pm)S@vwzJs}55?dZp+Zf;fXm{+W^5|VToB-AeCCemqx_>2g{wBmnq`AZX zDrH>JRPQ*6&SCkdArjw`n;J%RX-6T&<+AoqI5)v;S*60jnt)wyJ>S~ z!h9|8Tf8J#IZ$TT_U8gMUamv98#l#W3uqN-8RM7&73(X0sHT25z~J)UgYYK0j11<4`$Hv>`hW zDII-UWH1u%;_bA$KzX8Sh<-&NU^6E0nI;N@6~QE&uj2s&H5cDK-V@tqq2 z56}FToYG}nQ0D)&b%))x5{pb6%iuX|#UGhAK1;axkV z49>_~KZA^?Nj?*cwRmF*iv#Ex71>VJAdqQCcoCP2f1u)}FF)TK3oUbBY#e|A+NxC_ zj_4_J)`FVmvrT}E&t;M_{^p$RaUR!7zC9d=^P@wAN;K|{73d16-i32IE`1*K!ehFc zR;9M(`}nWtaaHeSkZ$wbKp5)wNVQ4ca&syGRL`#wdO1vj5(Hel=2P zjD&&{8Q#kFI|PiZeN;{Ya8qkF0}j;697@gGBUXnJ6RKRynf$?l&s3(+sT&tuTpwYz z^gK!BSO@-%!6(YT{Q&jD=@`#}|hZaLi0QL+U`(6m1D>^U7&D}MKW>Ha%^e@Y!y z`BK%}=Ce-bZTL%CR0?2qY7SLstmcbhHk{R9@LqDjPg?gS-E4(*uR30CJbcGbq#%LX zVqH}q^E0(8H}-gTN!!{#)lWt;Smq1`8iev(W1M zk8_yHfVR=zRYM{dic#yIa0|Gz->bd}jGVMW<`Ta_@Xw}zDHVX`i{AK2mjn0`w1Qh% zO5Nm~Xu_Ir%zX=@gov>vu82@8GQ~`@QFLvFccGVuf0E29+Pv{$*8R^~6*ZcpaDY%6 zM%I%l9_+xh>|kCLnjg&q{Tb)JN?lB_$oXH@qY%WoHe1Zlf3eQhl!)CTpyA_*48iN} zM}ubbzDa}|?|NpJq3iFp>T`f2w`_Pr^!d8M(nmol@%Y3!KS~SdLlY0Mg6NqGps09q zYObaF__PKCJ&O(Zc)?@LIV#$T8_q>N!8aa$YpW3eXI;U_c=scPQPcIxE#Mz4;fAv+ z7XVW~t%-Yt6FL~Lf2i-Ld|otkbGyv`OoFn$=Vx{DI;z>;0Fc+83~t`o;%Rn_$g~+# zk^Q-_t6mt@P3H_1R`KbZ$k55iLQ2C<$5`!znd*9*Gh02qMkwVJH?zy6VZ&a7O3jFS z1w||(`IN6_X((MQu$PH_nwp)NxIHku3GS)Rng8m@V?0864vEmAfLsv(z1)XWYf_VZ zLGDA**0`5=RDV7czy^5z-qkGQRPty8lV!(CYf@ufO7YK+Q9G9SyuqBdQ0GMfFG(A+JMPza9@Sl(6af}j^VHwjqd(Iy z$8lq;LxZ!0SIv!G%KPv(t@pu+-KK|Ficb>FuClOV?G<+;$OEr;sG=3Y2>; za!Bm_BO9>z^1d&NKCrMF2(ykB5$xz_Y4VPMh2k_1_yS_JomCKN#n|X%lqxnLB|fBJ zd;}KaDfRqC6vOpEy*`J6ZyCE1*P|)5Bfl7b@Qr+$)1^RPJO{q{5Z={zc_p1Lx$9Qn zarB20jeUr@i`fDUjx99nT&f}7@Q6mR1OntGZzko30+IoB$l7u4F^qw{m_fuZZdp1} zPkOS0q!w#9Mz#e?9M#yg97eT5|z0uL|5Es#%iJIgG#-o~SC}SxSu{?Y*7J z`u0xz-Yix|IL7sC<+bV51Uagvm%nrYVxJPbQ8tM~F@$PN*yV>zO9&yJzS-pqU3uk~ zyu))OYy*Tj&-MZ0Zr*RTs}CnqQ?cLln*x_O*;j1xw+=Io_ZSkd$IgG2hHZo%++xG~NICr&q^UsUP%MNLc zp}%2qQq=M4{*>kCZcjFNRP_32s}n5u(D0^V{64Lft2qaPFj7DJ$#8giPGkC{n-&F} z?j1PI6ePfO0c?MYczGaJJVymTh-Cf@Bs@ZNEpWTMvxkBcpnI-W#pG! zY#jkeM@lLwa64%iNU|iGW#B@mUQ=)#K@v*EX+Ap_CO8hiIcf4Ao6NVIK$R}!DDwfE z8CQ-Fh5nS}O|237p6DvxtU))v9Eacd%{4xTQ9PQ)`PY9KlNL%$wuN0zBaCLR;Y&F% zwv(iDyh7SFEp+KnwT{_}@`3vRZ8PNwUYRy?!pvSOytBe2A$iJ-@@rUf~A+c znW=2SOoyLu`cDEwIGQP0zemnJ8lLYJo)7#Y+ar0IhI`WZlS69mg#K$0P>6 zj;EsSB&^-y34&NGjfC+~@qJ6Eh9`I!ROOKZeSqb=TC^&ODxm@qZk<5cZnAhC=5k{A z)FGD&Bkbbpb-Bi9o3#pj2od>^{-O9UqZ5}-|o6yx&4 zFAYDJfCcMht+>K1q3nZm>9}wyLrF;e)R1 zFNoEYfS}Ve9JWxJ2>|Wat~Yk0S*()cGKC1i%vzCLoLUEUlINak(EL{XJ&7alnBokM zm_+aJ9v(JJNWf!r`>8UdoHP)2W_+IoR}=hu(oDX<6&9J+6=mt#bvNO{n2vfYMGm|O zM425$W$I_Qu=}c;Y2mT-xfMa;ZEFv7gYj;VCT@HNpwFqe7qNQE+Jdblh!@8JoK*sF zxeWLkwbYrG2#j{L(%5mTwbMXtHYuF!A6@HW{W{ zCD7OLt}})`i+Ru1JN2|C<46HL>dmJJ(2j*|&h$j#1d2@fR%w#Y>qsrK0!O8#b`+_% z;Y{vhHHwH3`-I)7^FCL&8At?axa8zr@X-KY4u$Q5S9($Z68&)1$q`1;1NL@pVp0Z1 z36|CCGch^tS+5Kg5eE+M#Dl*F$6?$yjrnIHR5rWfv^xgktkOuCObJhkJTfp zTc=5t;rE{f;c`X&O{spj*g0cN%hnO-Oj__qM4<#jC}TJ5d?31SuV^wYn8q@}W0$;K z4)F9HT!DSUi3N$=1od~R;A3KN6*LK1M9hX9?-30SoQ}eNN1hjFhK~zoPxLx z5s(CcKeTqAhBoty)at3&K{+U5iKD>~is;XIyp5?(yhs(ND@2m<^YOtM8Fq%>?`v+k zk~=WCYTvry*>Fq{jE6oZ+Wma3AJ`@fWguwpYs9=|aJ8ihIeVZVCx2S9XZ(r0?F-Q5 zEvsmNwiCJI-8rlMxdYV=ibafNFd};5wH~$9e}jZ`T%x>o$0A1TqJMRctbq4lIR50$ zdT@JLBdggN8uhITYs_B6P7m7u>4N|6FPVxs+MCW$gZv0A6Wuvr*;1RLP46~Sx4a7R zHuej^+QtYV40kiAiYykjW?Cr8#-?#S#XGX&*-vz2c@o1zEwtN zx^i33k#UGn83(S?p`YkjNgAKXJd(*##oaWTfEnjf`J?K=H4sUKJbkJ%L>q{ zm}x8~zjAAW=F}J&Ny#O=XCQTQU>l&e@Xm07#{WE7z$^zdi;ii7A#e5(1YDS}wZ>jD zu~*OQ>^68 zITR`soltiM*Tk1?7l!%}#__B&7UMuk6r=iOP5pa90JsbcR6Cj4UKy(SGm#}J-$BNw zigbRzD(PB)`47GvzIsVNI1?+m9#E1=fABUoszHa0l>oWe%T2pYzse*0yqRJBAa8-rPGD|0W=oShUudcTYQSK ziifCyKa0B+TWxKq*0k&+$R(*2+Rx&QSgU%^zaIn^!U(z1v3Z@d>(E~ev*h&Rc>mX= z_ms|U=7oNKi&-f=+Qt@RGy4BqVRz&cc&e7JeLayRj^bB49KM}9tDc`Kuy`Mym>by! zK}2=p=X*K^n7$BdUd%(r#+27gj^JJ}Ts7cTN)M`J_{5D&tnY=Kix0bUIx}ED3>9J& zXug=r9_+5qO~qDu;8iOiwWO!O4gYpLPU6Z>Vf}JuMq%_gPLrLkG&yx0g9aQ?-|>re zlxwssWptL>#25(ff-&Ih5{9(;goyv~w7Bc=@$US_PBt$V`!r)#^^0>UZ7d1*o&O1r zBAdDC<-2-%wZg9~GX5ax_wnJpFLEkx>O^xq5z?@dAW(lM`(uvrrt9(Z7o07p`Yc4G zEOHg3jKjZ`Ild93Z5S2Pr|lKX?6P}FL1#Zn54|Yk$RUE*s)+@Rdz(aeafPrXv={Ii zMq6kO`L{`ZzOe03lRy75v0UXnW6)Wt^lv4qE~T@K2v0iHI8u1zLx%M-eM;p?FRMtQ z;#~Xc3g|cI8M62vd{3xYl(qLm-|n|4QOjT?@=p4ZUeA|;Mg&P<@#WLkH5)+SmKQ4I z8{2@kAEqN3(XwnG!s#n_Tcj4v_(%&gaiS<`{-j=XvQM)Ft`~Y`SE4U(c|^+dNg~;( zG<_!Y24mSnbR$YM7@8hMvciY(b>}op+&2F+I5-zxmOopwNvSE29)4!$n=}`-dfrk0 z$A+iQM%lqgh3uWIxWJTFGt}9>sD4`~GNh>-X5nWc;o|Ym+^& z_uI>2V{)ZRoL?QUA24I7T}~3aPKRc50BuPZ=s_ngg>I@x4)b9rs8=z1roJyUGk0qp zu38L>Qp>iKg#p=nu6oTHf_Xs;D5qr38^hz?BkpQpd_5SH!!e^Z1#v!M{Ca6xP9UIn zyOeJu=g}dNu=~qR3;WkuFQKZ^pqHY#`4z>;!kX!PrOeU9&>WDR#X^y4PC#FSb$PF*w z<=BXI`WcuAvbhvE!}!p#&Syr_V;)CH{gpWuTA1zB*x#eeh9R}qS4Ym&=Or3W|B#3@6`Q zl!g{6vb({0UJ}gbIPn{qu*AI(kMA+Mn7 zrP;72VWz15R?Sa%+3Pga)hKbSNEF&v zP)wxiq=v$YQI;iM_?0IUzyffm!>^#0eD?A_e#^_O(0h#WIpX+xb?g-wGLxa+1@OeO zQ{ztJ!KGfKO`ClE__C|4!GmdN=e$-|Q|VqHc>(^ym=5+EW@p$e1zq43Px(+KsGC)I z(Y`E3 zt(oa5tr1$m5kOM}1z6t2<{pLSDu?C({^R{Q)cR_9@WXnXnVZ1Lu#ULHNTrP_7)9`e zEQ-E_DE|DdEqCuM#+f0wb^P#DR;=hHu?a9aYk(G%OC8w8633us>VtY{-3N`96-Fq2 zls0W>XovKk{H5S|eD*_kfO!YUdHs6pZAPST%95#xFsbf`R)Rv#VxGVTQO}wsB7CRM z==Icv%N&TxAs?|Bb60nB#23LEOv3RTxB>r7 z9{WE3YAeuk;8$k=X8qbx^<*Hl%-z+k_4Yo zC`MR)oab&NN-(|iQeIjoP>C^MdQ?p@Mj}-_adL5ub0N@O$yvNBg(b5y@K&8}5_0;U zNGT+;0O;EIN`MD@T~5OCWC8cNhMyELqjuXoCr*_%Js}CwuIRJikR@qSkUB^|`GL3l zcA-w0e+iFTHpJ@^=A?JTc3QW0dJ<{@v5DO0(V8~WKsletHcC7F5aGabis>ZHo)OK; zRE2+uqy>gLReagqK1CZttEG@M_U!mk26i(-WW|8@2ThH^9$d+XC2vOhR%(vN`9R2VB*vha2orEdJC>j#h#DgBAzqDB2+#2tZt(3G)SzvRL4&JT zL(PIl{5^xQBN#lC*)woPSXe`KRB2-YsrMQ!%pBSBT~!G?g<$5uO1l}P@2pU!GmIH~ zG1fT&BULFMYYf(YxRS4uRF*9q^C-9C+50gcu_X*n*apzNSxGlodf)nMK*#pRUHCfV zK|xZ8QPhK=BO2B-fESn(;w?=!lnOT%y>)*li*E~9D39x2{1x>oRqm6{2bV2ZCEPb_ zm<8v$_4HDDRln2>(LX>7Rps!{o@$0JPo3VcMW%on!cwF?8AVj>|8j9Mdvja4a(k-_ z_ld~(BOOif6=btkHBh9RDn;}}6O>;Dw0S<(*EVr}y<7W)k>?2M*PrkR6azntt>YNF zA?-_*oVPorvRPAnDK@;N_&(%DWkaE>OPw(1Wu^5PR=aWTQ})@~)npRf>($uOSy2{4e!B3oBUQY~ zdJK1n-kkHyKtxW|PIkZE8hEm!bk+dP*Epge#Cih-SOda|sEo`qx7BU&VgX&i;<$ty)s%Uc4pPC*DZ#D#}(g`%jIZ@Qw9r(CSs zO?ZN7Dbh_H9?m73b<8|aa$1SGM`aKUoY5V)jyX_;-K%E)(Yr)iluYff1vPXxHbfq` zwRtr&|KW7PcsA_Hdcf6J8f+P4fx_7l*L1T+0EZ_o`&cQGjmp%{@Ycb2H+ML&C*Cr2 zQuE5eV4A%6#TNGj0qT5@07TKscz-Fipqe*2wkU%-6MIzX7@!Ap>jj|-BBFVJm+rBb z7AuK7C_o4$yVt|2M>%_(th$B`=Yic=8{yO;L-)beLz!G?G>EL z*1a+P&Mrkpqn-N7#(G@eEH|ew*ls_ANIvBP;*$r{3z^loe56BcPz#dbIlbFvloUX} z2lso{`okTJV-^-l2Y`m%78zEDod3?8VE!s4^P(7TY|oE9ftG?d#${lyyT|g`=JfNa zfM}F|$(S1!tI4V-`nK+zK=h6eC@n0p%BN`3#bKLkyiLBR3XS=WjsxKmnd9(shO2-x zdDC!!>d&il@#i4PxVkQ&$iCG*)Aq`&8$np56v+kwh&lmmOYQ3~PA_g!7m}*+zV%mF zwFKkMDuLzcf6Kz!7|aDOWI<1Os2(VU^^!J8+9M^@zDYkly{OaJac6`F^p-s_SLJe0 zt~Ih1g4+8BwYWV;6hYgHZmMKbr^j*P5h>rqppB+F8;{bQB&co5Iny+g7p+-x6BXY(u^|0&O6s64`t!M*4k<4&2EONjw2L>|qG zP#(*BI0LD5FJ<}v>T+YNUhnj}E3sAS$u=Qlnr7!VRgZr8sx8Puq=;9l@ht^B$&fA* zh$hT;U^OdmiL3-&KqC#_l!&vA=H*kx7TaBA`GR!|zHb=J z)F0zNYn*r(0v%&J?yiQXWBa8_8q;}qUi#WD_IKfh(Wxlvpl5bpwN!CZU9DZNcL{tRwd(&ANbc~ig$|u1z&DL@sb2=}#=4z_1$6k=Lqeot*h?A*d5bwB zXGw^EG@l_#-=xuFn^r*>rpXb6MB(IvUOry1#iSm0G70@ajW=A|1`eM!U6Mf2>WRL{qP_Yv1BDQJ*`sr^YC5-fQr(5?kV zMWQ(Rw>9m`sBi$`q+7)&@_n!Mx@4sjz969Z>OKZDTM!1|%PFKM4%^6B*D5k43YBD> zjh$;)sT*=h*xS(9*~&oC4c#|F@sJPb;cUMM1NDt#Rcw1KWav>ju|cV7Cd=!Dx>w^` z)q_UDYMw*279En~oTEN4p-JBJ^@a!NU)tY1TTpT&8xuyP{aD5Xkid2ixTrJd^D8ik zmk=k(hleD=1JH9e`#WGwoQu!f(D&>gLjY4GlSfXqboEsWXakA3g8y-2|~ zjW0%{cBbI{AJftp6u4$>_@`DdQ5t~)5qcwB+%?c$_pc6C1h#=QAb#f`-r23zC%PGS z0Y@B#p}X(x6Kn8yh6z+8Xg_MXjlSeb1BKDJDg@KhpR+aJimNF-F&?2MH#6JXs^rJC zeuKg5(mkjM4||UJvy($r3sQ4(wtA_s!`6ulV3qy+o;jfFAhLv}#-n$DPT7 z9J%O4nTScBBp4Xd8;iM0?qh175i&|evN_H$i6X?R*%gI#pv|qJ9+6)qF8KD$CZNmv z`}o8;oi|EwlKQkApFudcA$9nGo;C?xhMe-Ng)cztI3!BvC4Cjc8dQj=h2TWc1+J`T zU!uy<1H0Akm0B-5LHGXNh-V%jY2O+a7 z1P%Ibewo3UK%i}d^j8T;ZNmkW51&MC)#6f*9!o~B=KA9r&t|JN;>q$89`I8lBCgeP z2Y`#)ueVY8fm*;(Fjr$WRL{cCmye$G@Mew#!wn|Ddclu2WA{0_H9D`?9p&iwISuuv zLrk6eJ%XBnUzYFU{JeeQEHzkEUjVBb_S?Khu%}m@AMZbmIW)SiV!_3>t#Ve9mCMfX zvwK-y`M5xRR&GCaE21_^TYQM?vq6~m@@6TIR@0BY!~=v}9V_=VtXLkI^iV-6&6Awi z9v$7ZLVdICKhCKBi*5xhZlOQ`NZJYo4I#kWAc=S>oKj8r%udHe7g>6kGO7_i$+QcrK&86ax$sNqgQQ07u z+OdfsrFqYtWLj!}3t#8e@<;r_6jQ0m67Et>TqDY1ts4b73UO#mKuE0FQ?;&b^tyq6 z0=np&alTuvRA%J?xHvc^B=xJ^{j=)2y!qs|>?s3v{~FeY6PX6+Ot@eJZ~qpzwhC19 z@Ne$&pC7}#|0CGg{r04yFd->(Z{l!7y_OpGxqpF7O}1j~X7}A~7==&vr{EsG+rIL5 z8#g*I6z8^T3wd*fF4O1?g#o#99Vgy8n;a{^kRRI%xV(cr=Xv!=Q zU^TQI>%Jf6?eI&ZhxF^7fm6;^k>2lpKq@_KVgSSg&dZid=Lc68NMsQ}VE_D$ba{3f z!O~9=-s<{)mkhoau57f$>U2w9Gyu$#Gqs$-KKR9;%oC+(ovTx$+YR4oAI7j6hYp;N zA|%BUA`ifSxMfMPAJ(V1lwaD7br&>|BwcN&*LH#JRc_88exr=>r}aW_L=2%hxCSN( zly(gA$`vuMSmgnic>YN+bk*}He7DBkJiL!fYF({*);XW!v^TJl(6z|IFju|FaWR3F zJzw5xYbkhYyi7Ef9^6;J1R)x}OXZuVrs;kJ&t_pWdnBG4)VCN6;DhC@+OrtH8V`4cUN8b)lvfcTFK%UXzMVoE(DwJ7lJATY8@Gk_{7%ggt>zBsyp z3)+L5d>gBTaxmKrKy=enUSPAwu?b@UxILIXa%rUhEM#v9!#@Fl;gcC`HBX-_Qm$tX ztw4*r($KnU`&1c?DHfkPlJb(q86Uo$i6l5F%RkWfBM)o`%D8AJSc1W<`6OO}H*5#v zYo)Hax3;Yy&nns!>dGCee|+)btRnSE%9SC!KrF_^>nU=IK7d=R)88^{FJzO}31l2zTl zeO6_c^N%8a>~0`%KrdM|3qAq;t%7!ekCCH+-moIPh8a$G8%70*SAx}%^TI#(w1xbY z^`F&|HZ|4J5Hn3lYuwVUo>b(7ze!|cRFC`S8ViWK>hd+~ zMXj4CBm({N=-yu7;BZE{8PoJMkq#K-jHhN^g{#hq8GtFOfn`)7+*vP`1t5b_m+y9_ z^5oCTQK$aj@;a{MqpSn6VpY1%a?FX;`z7rq$1#-{?7#LW#jIca|GV-v#nSq>Ok3@@O!iP^S-uVMFoZ2-Qo8=ZGe)R+te zq}D|Ojicg3hRe3LjhdV%IrItx8wF8{Mb*z`%PP6z=8=*w_j`1fwve^|gjw*qk# zN~!J%nMR^p?g=uOyTH-x6CE2-jtVO2ob$&|>BHday;}1fg-mFZbedRcY{}E}Wo8;w zDK4_+bNHAsvo(-#&N7mT_#M8hOIBBrv`C>4whDG5tH&^1wlL7nU|&t&E?>s3$Y0r1 zzBJd#&x1dFKcy=4rJ#&u3Y*&&U4ewrA#^v1VTE8525}xy@Nx61VvH}-pEZ;tjH@% zFV=iI(0)ccoXuAW!bNI)5${!%wYsmIdXi0z=u$^Tug;sOTSzAHYKu39j%4WjpBuj{ zBlj7?C2j|#XulO@*>ZMJrS+57Qhk1`s@leZLRHl}zZapMfbID}85zj-Ka8e4hs*Ck zo7M19ju=MM|QDvTWH_duD4JZCL6y}#DvQ$+>O zqIBP~jYuAS_&stN*(PyG(EF?^%)lR}F3k7TbMK?;fzhxQ(Za2~Qa$`ApUIzM6Tzy? zHXt^NGBCDm>u4%J?CSN{2^S93lQ$u6x+x1%h0d+M;H>C@`eYnvPUD2FIOxnP@1OW z-`2O18O>ym6hm5l3p6KXeka#;$d1zcWOVDl=Wra=oRKtbH`={xjvW*ct-TogMEXXB za|m585XRa1ajVj9cT~|U3Z&uWdk~&@ugC0G4jsq8z7zQyEjk_f4un3a8*!vcG3c+1 zqdI~1{wg5V?ut~Rv)OLy7e~FljoFFs5nq9e=4&+h(@L1$;ctphTx)n1wvaH>aV@Td z1-+jw5v%FC%&A__q7P;@J}Ssk#d>Vd@TD_E`4jTQ=eg>8UXmgppd;>Kf7ITt-nlGY@NFNQ4F?(^0F@NL?J+&LM?GgT2~zu zI?aqJg6(UMgqH_8un+4j$gBF?aS$`rt%drzuH*dh$L&76$c!IL*SVkJ#okfuw**!V zoGIdUgDAeLueR%n@Xwz}E@~W3SCm^!_CF1KdEp&?s{(Qq-KCt_k)5neCp(IEcCy~u zB+maO>8aQbW{OPuf}iQ;81h#A>mzd*2N5&xJM{Ghd{-2`L~$Q_BTD(lsfbHQ-qhmu zvTeu}wDVn%a$=o~jw$v)Q-ha^vG5Jmq9$XOg-1RUtTb~?Kh?iDCTQSEl94GRK{!E_~(inJh}U`iu6Bn zUZA#bTJ*5D>018s2sJevqOnisHd5$XT8{3^e574$vMn*p$Yz_@FXe2)W&in~cM5rJ z;o%tUXO#<5nXmsLH*?HTvUlQOHKnV}oULU0)wN(Qh6t0HxRx=Sa=oX;U0qm&$7NeV zN0J%A_6E{i6i7`l#(N=Yai63F(6Ro3GlF7%?X~leWaZTquaP#vz5AAYs4(Zhlk#SJ zjcE)4tLX?=j3#Gk|KtxHnYSo1OhsHuC9q9Frop}?!uxj|eGC?0j4v6geUHgR>&>MH zfp3UmCfd9I>b13Vjq2XTj2vepWVKT$YnN4hB2nfSnPQpGNf8HG?AB`R*tugk+N|79 zxqn3UU?h{N+09pg-BIdAn7jeMOJ@`}evwo!S-{fw?2tb>+iao-B2g@`x{1pL{}cNI z{N61z6{mu~x$t7|=yJd8W#|lqH-ftO4alOBTali1Tg;zlbT`0Bs4pJO=C-ovJ-NvIACvBPt|y23uNTn*{|Y#Opc zp*&l&N9PGZ8rT&TH61pKl=-oiZ#M44nZIlIR!{;M=@@F9F9hQP=dO7^BvU`aVt*j7rfY*a4k8u#i9^AL3I z29mBfp8xCoxG+A4uN|_ZmDG3@A*W}e5$RJ&IfQ1b)|Mm>qsYo=F>tWOqp66RiFd(Y z&x07K1hOD|1~Q0lZi7u(LrLSqC-Dao6BOOSlmLG6g5*sec0^-zv5~^W6^d74Wi$36 zQ6&QM#cyB(a{wjfd|@-+dV-+l_z2dSFR9!C2PP+CtiyZf>@e%4k7U9ZgC}Yvzbw1$ zRGIme2RmhGm*jZBd`O7YN+$z6WPeRK{MX&vOI{yk=Vkl68o>ZYsu+dfM99SxMU!k- zJtW&>6`-uKz9+$1mIY7~eHDlke*egF*~?PTJp(}|M8QY3iG6>{wN8!LuiM5lkni2@ zbMQ~o^VBl1futGj&eGwANivZ~*JXz~vH}xefeK5ja1FeID^<9!cX={O=Lzp@oxh<> zTFVJ!9A1=I8PbgO7=Oeu1jy2EHL^*Qan=>x`>CjJALlLqPZp- zVe)RnrbpvplgYwH7s5vhB|Ve9nfwAm^9Tftx&MkA$_UxMtmRXBVd$Z+t#jd8g#RkG z**o0_hO7z`jq?2KGn_}NhFm{%bILI7vsyr~o4ctXiOa2T5${P5o1c&f`8&`XpMkqe zW;$_2iOg8pem0`hg+nktxpV&eM>=T~wN@(FZHWxEfppE{}wL?%#kV+oPOh>5lsx3Q9F#ly|&G)2snqUh}`6AY)wwTW5w+=TAeb{*f=fII!FWY&lA; zoS>;8b+ARA&a{7g$<=5C%dF#n8T{xvj!CC$BVTRkdhCF2Cw-}s-13eLa3g)_x+*%EbrQ&?QH1h z`~gMwE7s_x@lg!lk+!nfZIg^v@cX@OUpbITSLxd)E+4IPi`e{-X;0wyAra#V^ zgK<^SGsg^g75oRvG3J9zyA)P6v4R3G+6YXC((|48rY=}?i6{L~YYJcmCzbP}JbqsR zp#-I8G3(RfoanZxIl(kc>jd!MNjCTGt{6ai`_IU@GWZ@hgq> z-27CKKs+;d_CFo1C(~2oE4)Ib*7})N@Isdfs5jhUmurqJo^r8G_qh0F&l*vt)o%Q$ zofZ#CtSV?S_z+ZBUfU(fxJxfef&_NfiEH>|ufi``>sm!#M4C`$=OpwnwWrv-xfCFF1X?ajj%{}KxKL0KYm@$-i;j}� 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 Fpa6EOWaR(= literal 0 HcmV?d00001 diff --git a/src/@seed/api/config/config.types.ts b/src/@seed/api/config/config.types.ts index 4840525e..746ecb18 100644 --- a/src/@seed/api/config/config.types.ts +++ b/src/@seed/api/config/config.types.ts @@ -1,3 +1,14 @@ export type ConfigResponse = { allow_signup: boolean; + branding: { + logo_url: string; + home_hero_image_url: string; + home_heading: string; + home_text: string; + login_heading: string; + login_text: string; + home_content_mode: string; + }; + hidden_navigation: string[]; + integrations: { salesforce: boolean; better: boolean }; } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 7f2fdbaf..7bad0421 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from '@angular/common' import { provideHttpClient } from '@angular/common/http' import type { ApplicationConfig } from '@angular/core' import { inject, Injectable, isDevMode, provideAppInitializer } from '@angular/core' @@ -8,8 +9,9 @@ import { provideAnimations } from '@angular/platform-browser/animations' import type { RouterStateSnapshot } from '@angular/router' import { provideRouter, TitleStrategy, UrlSerializer, withInMemoryScrolling, withRouterConfig } from '@angular/router' import { provideTransloco, TranslocoService } from '@jsverse/transloco' -import { firstValueFrom } from 'rxjs' +import { catchError, firstValueFrom, of, tap } from 'rxjs' import { provideSEED } from '@seed' +import { ConfigService } from '@seed/api' import { LowerCaseUrlSerializer } from '@seed/routing/url.serializer' import { appRoutes } from 'app/app.routes' import { provideAuth } from 'app/core/auth/auth.provider' @@ -82,6 +84,23 @@ export const appConfig: ApplicationConfig = { return firstValueFrom(translocoService.load(defaultLang)) }), + provideAppInitializer(() => { + const splashLogo = inject(DOCUMENT).querySelector('seed-splash-screen img') + return firstValueFrom( + inject(ConfigService).config$.pipe( + tap(({ branding }) => { + if (!splashLogo) return + splashLogo.src = branding.logo_url || 'images/logo.svg' + splashLogo.alt = branding.logo_url ? '' : 'SEED Platform logo' + splashLogo.hidden = false + }), + catchError(() => { + if (splashLogo) splashLogo.hidden = false + return of(null) + }), + ), + ) + }), provideAuth(), provideIcons(), provideSEED({ diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 86607d3d..c1ca9ff1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -56,6 +56,7 @@ export const appRoutes: Route[] = [ component: LayoutComponent, resolve: { initialData: initialDataResolver, + config: configResolver, }, children: [ { diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts index 542d8ba8..57912dd1 100644 --- a/src/app/core/navigation/navigation.service.ts +++ b/src/app/core/navigation/navigation.service.ts @@ -304,14 +304,14 @@ export class NavigationService { this.getNavigation() } - getNavigation(): NavigationItem[] { + getNavigation(hiddenNavigation: string[] = []): NavigationItem[] { const currentUrl = this._router.url if (currentUrl.toLowerCase().startsWith('/properties')) { this.navigation[0].children = this.inventoryChildrenProperties } else if (currentUrl.toLowerCase().startsWith('/taxlots')) { this.navigation[0].children = this.inventoryChildrenTaxlots } - return this.navigation + return this.navigation.filter((item) => !item.id || !hiddenNavigation.includes(item.id)) } updateBadge(itemId: string, navigationName: string, title: string | number) { diff --git a/src/app/layout/layouts/landing/landing.component.html b/src/app/layout/layouts/landing/landing.component.html index b6ac1e7f..da183de2 100644 --- a/src/app/layout/layouts/landing/landing.component.html +++ b/src/app/layout/layouts/landing/landing.component.html @@ -34,20 +34,44 @@
-
-
Welcome to
-
SEED Platform™
-
-
{{ t('MARKETING_BULLET_1') }}
-
{{ t('MARKETING_BULLET_2') }}
-
{{ t('MARKETING_BULLET_3') }}
+ @if (config$ | async; as config) { +
+ @if (config.branding.login_heading) { +
{{ config.branding.login_heading }}
+ } @else { +
Welcome to
+
SEED Platform™
+ } +
+ @if (config.branding.login_text) { +
{{ config.branding.login_text }}
+ } @else { +
{{ t('MARKETING_BULLET_1') }}
+
{{ t('MARKETING_BULLET_2') }}
+
{{ t('MARKETING_BULLET_3') }}
+ } + }
- SEED Platform logo + @if (config$ | async; as config) { + + }
+ @if (config$ | async; as config) { + @if (config.branding.login_heading || config.branding.login_text) { +
+ @if (config.branding.login_heading) { +
{{ config.branding.login_heading }}
+ } + @if (config.branding.login_text) { +

{{ config.branding.login_text }}

+ } +
+ } + }
diff --git a/src/app/layout/layouts/landing/landing.component.ts b/src/app/layout/layouts/landing/landing.component.ts index b9e583d9..7feeb337 100644 --- a/src/app/layout/layouts/landing/landing.component.ts +++ b/src/app/layout/layouts/landing/landing.component.ts @@ -1,6 +1,8 @@ import { CdkScrollable } from '@angular/cdk/scrolling' -import { Component, ViewEncapsulation } from '@angular/core' +import { AsyncPipe } from '@angular/common' +import { Component, inject, ViewEncapsulation } from '@angular/core' import { RouterOutlet } from '@angular/router' +import { ConfigService } from '@seed/api' import { SEEDLoadingBarComponent } from '@seed/components' import { SharedImports } from '@seed/directives' @@ -8,6 +10,8 @@ import { SharedImports } from '@seed/directives' selector: 'layout-landing', templateUrl: './landing.component.html', encapsulation: ViewEncapsulation.None, - imports: [CdkScrollable, RouterOutlet, SEEDLoadingBarComponent, SharedImports], + imports: [AsyncPipe, CdkScrollable, RouterOutlet, SEEDLoadingBarComponent, SharedImports], }) -export class LandingLayoutComponent {} +export class LandingLayoutComponent { + readonly config$ = inject(ConfigService).config$ +} diff --git a/src/app/layout/layouts/main/main.component.html b/src/app/layout/layouts/main/main.component.html index 824d030a..5277a6f3 100644 --- a/src/app/layout/layouts/main/main.component.html +++ b/src/app/layout/layouts/main/main.component.html @@ -11,8 +11,16 @@
-
- SEED Platform logo +
+
@@ -20,7 +28,12 @@ diff --git a/src/app/layout/layouts/main/main.component.spec.ts b/src/app/layout/layouts/main/main.component.spec.ts new file mode 100644 index 00000000..b9754158 --- /dev/null +++ b/src/app/layout/layouts/main/main.component.spec.ts @@ -0,0 +1,40 @@ +import { TestBed } from '@angular/core/testing' +import { ActivatedRoute, Router } from '@angular/router' +import { NEVER } from 'rxjs' +import { ConfigService, VersionService } from '@seed/api' +import { SeedNavigationService } from '@seed/components' +import { MediaWatcherService } from '@seed/services' +import { NavigationService } from 'app/core/navigation/navigation.service' +import { MainLayoutComponent } from './main.component' + +describe('MainLayoutComponent', () => { + it('clears nav hover when collapsing and expands on the next click', () => { + let hovered = true + const handleMouseLeave = jasmine.createSpy('handleMouseLeave').and.callFake(() => { + hovered = false + }) + + TestBed.configureTestingModule({ + providers: [ + { provide: MediaWatcherService, useValue: { onMediaChange$: NEVER } }, + { provide: NavigationService, useValue: { getNavigation: () => [] } }, + { provide: SeedNavigationService, useValue: { getComponent: () => ({ handleMouseLeave }) } }, + { provide: VersionService, useValue: { version$: NEVER } }, + { provide: ConfigService, useValue: { config$: NEVER } }, + { provide: Router, useValue: { events: NEVER } }, + { provide: ActivatedRoute, useValue: {} }, + ], + }) + + const component = TestBed.runInInjectionContext(() => new MainLayoutComponent()) + component.navigationAppearance = 'default' + component.toggleNavigationAppearance() + expect(component.navigationAppearance).toBe('dense') + expect(hovered).toBeFalse() + expect(handleMouseLeave).toHaveBeenCalledTimes(1) + + component.toggleNavigationAppearance() + expect(component.navigationAppearance).toBe('default') + expect(handleMouseLeave).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/app/layout/layouts/main/main.component.ts b/src/app/layout/layouts/main/main.component.ts index 125c0181..75026ba0 100644 --- a/src/app/layout/layouts/main/main.component.ts +++ b/src/app/layout/layouts/main/main.component.ts @@ -3,7 +3,7 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, inject, ViewEncapsulation } from '@angular/core' import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router' import { filter, Subject, takeUntil, tap } from 'rxjs' -import { VersionService } from '@seed/api' +import { ConfigService, VersionService } from '@seed/api' import type { NavigationItem } from '@seed/components' import { SEEDLoadingBarComponent, SeedNavigationService, VerticalNavigationComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' @@ -33,6 +33,7 @@ export class MainLayoutComponent implements OnInit, OnDestroy { private _navigationService = inject(NavigationService) private _seedNavigationService = inject(SeedNavigationService) private _versionService = inject(VersionService) + private _configService = inject(ConfigService) private _router = inject(Router) private _route = inject(ActivatedRoute) @@ -43,15 +44,21 @@ export class MainLayoutComponent implements OnInit, OnDestroy { version: string sha: string type: InventoryType + hiddenNavigation: string[] = [] + brandingLogoUrl = '' ngOnInit(): void { - this.navigation = this._navigationService.navigation + this._configService.config$.pipe(takeUntil(this._unsubscribeAll$)).subscribe(({ hidden_navigation, branding }) => { + this.hiddenNavigation = hidden_navigation + this.brandingLogoUrl = branding.logo_url + this.navigation = this._navigationService.getNavigation(this.hiddenNavigation) + }) this._router.events .pipe( filter((event) => event instanceof NavigationEnd), tap(() => { - this.navigation = this._navigationService.getNavigation() + this.navigation = this._navigationService.getNavigation(this.hiddenNavigation) }), ) .subscribe() @@ -96,5 +103,8 @@ export class MainLayoutComponent implements OnInit, OnDestroy { */ toggleNavigationAppearance(): void { this.navigationAppearance = this.navigationAppearance === 'default' ? 'dense' : 'default' + if (this.navigationAppearance === 'dense') { + this._seedNavigationService.getComponent('mainNavigation')?.handleMouseLeave() + } } } diff --git a/src/app/modules/auth/sign-in/sign-in.component.html b/src/app/modules/auth/sign-in/sign-in.component.html index 67c8d7ba..e49badc3 100644 --- a/src/app/modules/auth/sign-in/sign-in.component.html +++ b/src/app/modules/auth/sign-in/sign-in.component.html @@ -71,15 +71,13 @@ color="primary" formControlName="terms" > - I agree with the - NLR Data TermsI agree with the  + Data Terms @if (isTermsInvalid) { - You must accept the NLR Data Terms. + You must accept the Data Terms. }
diff --git a/src/app/modules/auth/sign-up/sign-up.component.html b/src/app/modules/auth/sign-up/sign-up.component.html index 67dd85bb..94062bf2 100644 --- a/src/app/modules/auth/sign-up/sign-up.component.html +++ b/src/app/modules/auth/sign-up/sign-up.component.html @@ -61,15 +61,13 @@
- I agree with the - NLR Data TermsI agree with the  + Data Terms @if (isTermsInvalid) { - You must accept the NLR Data Terms. + You must accept the Data Terms. }
diff --git a/src/app/modules/datasets/datasets.component.ts b/src/app/modules/datasets/datasets.component.ts index d9d73d74..87b0b658 100644 --- a/src/app/modules/datasets/datasets.component.ts +++ b/src/app/modules/datasets/datasets.component.ts @@ -39,6 +39,8 @@ export class DatasetsComponent implements OnDestroy, OnInit { gridApi: GridApi gridTheme$ = this._configService.gridTheme$ orgId: number + // Set via the '?create=true' query param (e.g. linked from the dashboard) to auto-open the create dataset dialog + private _openCreateOnLoad = false ngOnInit(): void { // Rerun resolver and initializer on org change @@ -48,6 +50,8 @@ export class DatasetsComponent implements OnDestroy, OnInit { // }) // }) + this._openCreateOnLoad = this._route.snapshot.queryParamMap.get('create') === 'true' + this._userService.currentOrganizationId$ .pipe( tap((orgId) => { @@ -63,7 +67,13 @@ export class DatasetsComponent implements OnDestroy, OnInit { }), takeUntil(this._unsubscribeAll$), ) - .subscribe() + .subscribe(() => { + if (this._openCreateOnLoad) { + this._openCreateOnLoad = false + void this._router.navigate([], { relativeTo: this._route, queryParams: {}, replaceUrl: true }) + this.createDataset() + } + }) } setColumnDefs() { diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html index 0854878f..c62a94d1 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.html @@ -48,7 +48,7 @@
- @if (organization && organization.bb_salesforce_enabled) { + @if (salesforceEnabled && organization && organization.bb_salesforce_enabled) {
{{ t('Salesforce portfolio functionality is enabled.') }} @@ -240,7 +240,7 @@ - @if (isLoggedIntoBbSalesforce) { + @if (salesforceEnabled && isLoggedIntoBbSalesforce) { }
diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.spec.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.spec.ts index 7545037e..f26663d4 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.spec.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.spec.ts @@ -6,7 +6,7 @@ import { Router } from '@angular/router' import type { CellValueChangedEvent, GridApi } from 'ag-grid-community' import { BehaviorSubject, of, ReplaySubject } from 'rxjs' import type { Column, CycleGoal, Goal, GoalPagination, GoalProperty, Organization, PortfolioSummary, WeightedEUI } from '@seed/api' -import { ColumnService, GoalService, LabelService, OrganizationService, SalesforcePortfolioService, UserService } from '@seed/api' +import { ColumnService, ConfigService as DeploymentConfigService, GoalService, LabelService, OrganizationService, SalesforcePortfolioService, UserService } from '@seed/api' import { ConfigService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import { PortfolioSummaryComponent } from './portfolio-summary.component' @@ -102,6 +102,7 @@ describe('PortfolioSummaryComponent', () => { let fixture: ComponentFixture let orgSubject: ReplaySubject + let deploymentConfigSubject: BehaviorSubject<{ integrations: { salesforce: boolean; better: boolean } }> let goalsSubject: BehaviorSubject let columnsSubject: BehaviorSubject @@ -120,6 +121,7 @@ describe('PortfolioSummaryComponent', () => { beforeEach(async () => { orgSubject = new ReplaySubject(1) + deploymentConfigSubject = new BehaviorSubject({ integrations: { salesforce: true, better: true } }) goalsSubject = new BehaviorSubject([]) columnsSubject = new BehaviorSubject([]) @@ -139,7 +141,10 @@ describe('PortfolioSummaryComponent', () => { await TestBed.configureTestingModule({ imports: [PortfolioSummaryComponent], providers: [ - { provide: OrganizationService, useValue: { currentOrganization$: orgSubject.asObservable() } }, + { + provide: OrganizationService, + useValue: { currentOrganization$: orgSubject.asObservable(), updateOrganizationUser: () => of({}) }, + }, { provide: GoalService, useValue: { @@ -168,6 +173,7 @@ describe('PortfolioSummaryComponent', () => { { provide: MatDialog, useValue: { open: dialogOpenSpy } }, { provide: Router, useValue: { navigate: routerNavigateSpy } }, { provide: SnackBarService, useValue: { success: jasmine.createSpy('success'), alert: jasmine.createSpy('alert') } }, + { provide: DeploymentConfigService, useValue: { config$: deploymentConfigSubject.asObservable() } }, { provide: ConfigService, useValue: { gridTheme$: of(null) } }, ], schemas: [NO_ERRORS_SCHEMA], @@ -195,6 +201,13 @@ describe('PortfolioSummaryComponent', () => { // ─── ngOnInit: organization and token ──────────────────────────────────────── describe('ngOnInit — organization loading', () => { + it('should skip Salesforce verification when disabled on this deployment', () => { + deploymentConfigSubject.next({ integrations: { salesforce: false, better: true } }) + orgSubject.next(mockOrganization) + expect(verifyTokenSpy).not.toHaveBeenCalled() + expect(component.isLoggedIntoBbSalesforce).toBeFalse() + }) + it('should set isLoggedIntoBbSalesforce true when token is valid', () => { verifyTokenSpy.and.returnValue(of({ valid: true })) orgSubject.next(mockOrganization) diff --git a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts index 9aff1fba..c2f3797b 100644 --- a/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts +++ b/src/app/modules/insights/portfolio-summary/portfolio-summary.component.ts @@ -10,7 +10,7 @@ import { AgGridAngular } from 'ag-grid-angular' import type { CellClickedEvent, CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import { Chart } from 'chart.js/auto' import annotationPlugin from 'chartjs-plugin-annotation' -import { filter, map, of, Subject, switchMap, take, takeUntil } from 'rxjs' +import { combineLatest, filter, map, of, Subject, switchMap, take, takeUntil } from 'rxjs' import type { Column, CycleGoal, @@ -24,7 +24,7 @@ import type { PropertyViewLabel, WeightedEUI, } from '@seed/api' -import { ColumnService, GoalService, LabelService, OrganizationService, SalesforcePortfolioService, UserService } from '@seed/api' +import { ColumnService, ConfigService as DeploymentConfigService, GoalService, LabelService, OrganizationService, SalesforcePortfolioService, UserService } from '@seed/api' import { NotFoundComponent, PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { MaterialImports } from '@seed/materials' @@ -82,6 +82,7 @@ export class PortfolioSummaryComponent implements OnInit, OnDestroy { @ViewChild('canvas') canvas!: ElementRef private _columnService = inject(ColumnService) + private _deploymentConfigService = inject(DeploymentConfigService) private _goalService = inject(GoalService) private _labelService = inject(LabelService) private _matDialog = inject(MatDialog) @@ -106,6 +107,7 @@ export class PortfolioSummaryComponent implements OnInit, OnDestroy { } isLoggedIntoBbSalesforce = false + salesforceEnabled = true goals: Goal[] = [] currentGoal: Goal | null = null goalSearchCtrl = new FormControl('') @@ -222,12 +224,15 @@ export class PortfolioSummaryComponent implements OnInit, OnDestroy { showDQBanner = true ngOnInit(): void { - this._organizationService.currentOrganization$ + combineLatest([this._organizationService.currentOrganization$, this._deploymentConfigService.config$]) .pipe( takeUntil(this._unsubscribeAll$), - switchMap((organization) => { + switchMap(([organization, config]) => { this.organization = organization - return this._salesforcePortfolioService.verifyToken(this.organization.id) + this.salesforceEnabled = config.integrations.salesforce + return this.salesforceEnabled && this.organization.bb_salesforce_enabled + ? this._salesforcePortfolioService.verifyToken(this.organization.id) + : of({ valid: false }) }), ) .subscribe((r) => { diff --git a/src/app/modules/inventory-detail/detail/header.component.ts b/src/app/modules/inventory-detail/detail/header.component.ts index 848050bb..586d3e76 100644 --- a/src/app/modules/inventory-detail/detail/header.component.ts +++ b/src/app/modules/inventory-detail/detail/header.component.ts @@ -8,7 +8,7 @@ import { AgGridAngular } from 'ag-grid-angular' import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import { filter, take, tap } from 'rxjs' import type { AccessLevelInstance, Cycle, Label, Organization } from '@seed/api' -import { AuditTemplateService, CycleService, InventoryService } from '@seed/api' +import { AuditTemplateService, CycleService, ConfigService as DeploymentConfigService, InventoryService } from '@seed/api' import { LabelComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ConfigService, ConfirmationService } from '@seed/services' @@ -41,6 +41,7 @@ export class HeaderComponent implements OnInit, OnChanges { @Output() changeView = new EventEmitter() @Output() refreshDetail = new EventEmitter() private _configService = inject(ConfigService) + private _deploymentConfigService = inject(DeploymentConfigService) private _confirmationService = inject(ConfirmationService) private _auditTemplateService = inject(AuditTemplateService) private _cycleService = inject(CycleService) @@ -64,8 +65,13 @@ export class HeaderComponent implements OnInit, OnChanges { gridApi: GridApi gridTheme$ = this._configService.gridTheme$ actions: { name: string; action: () => void; disabled: boolean }[] = [] + salesforceEnabled = true ngOnInit(): void { + this._deploymentConfigService.config$.subscribe(({ integrations }) => { + this.salesforceEnabled = integrations.salesforce + this.buildActions() + }) this.enableMap = Boolean(this.view.state.ubid && this.view.state.bounding_box && this.view.state.centroid) this.setAliGrid() this.buildActions() @@ -181,7 +187,7 @@ export class HeaderComponent implements OnInit, OnChanges { }, ] : []), - ...(this.org?.bb_salesforce_enabled && isProperties + ...(this.salesforceEnabled && this.org?.bb_salesforce_enabled && isProperties ? [ { name: 'Update Salesforce', diff --git a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts index b3c26494..ac0f037e 100644 --- a/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts +++ b/src/app/modules/inventory-list/list/modal/more-actions-modal.component.ts @@ -2,7 +2,7 @@ import type { OnDestroy } from '@angular/core' import { Component, inject } from '@angular/core' import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog' import { finalize, Subject, switchMap, take, tap } from 'rxjs' -import { DataQualityService } from '@seed/api' +import { ConfigService, DataQualityService } from '@seed/api' import { ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { UploaderService } from '@seed/services/uploader' @@ -17,6 +17,7 @@ import { RefreshMetadataModalComponent } from 'app/modules/inventory-list/list/a }) export class MoreActionsModalComponent implements OnDestroy { private _dataQualityService = inject(DataQualityService) + private _deploymentConfigService = inject(ConfigService) private _dialog = inject(MatDialog) private _uploaderService = inject(UploaderService) private _dialogRef = inject(MatDialogRef) @@ -64,6 +65,12 @@ export class MoreActionsModalComponent implements OnDestroy { { name: 'UBID: Decode', action: this.tempAction, disabled: !this.data.viewIds.length }, ] + constructor() { + this._deploymentConfigService.config$.pipe(take(1)).subscribe(({ integrations }) => { + if (!integrations.salesforce) this.actionsColumn2 = this.actionsColumn2.filter(({ name }) => name !== 'Salesforce: Update') + }) + } + tempAction() { console.log('temp action') } diff --git a/src/app/modules/inventory/actions/analysis-run-modal.component.ts b/src/app/modules/inventory/actions/analysis-run-modal.component.ts index 6ba2235a..2768e4fe 100644 --- a/src/app/modules/inventory/actions/analysis-run-modal.component.ts +++ b/src/app/modules/inventory/actions/analysis-run-modal.component.ts @@ -5,7 +5,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' import { RouterModule } from '@angular/router' import { Subject, takeUntil, tap } from 'rxjs' import type { AnalysisConfig, AnalysisCreateData, AnalysisServiceType, CurrentUser, Cycle } from '@seed/api' -import { AnalysisService, CycleService, UserService } from '@seed/api' +import { AnalysisService, ConfigService, CycleService, UserService } from '@seed/api' import { ModalHeaderComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { SEEDValidators } from '@seed/validators' @@ -28,6 +28,7 @@ import { BetterConfigComponent, BurConfigComponent, HvacConfigComponent, SimpleC }) export class AnalysisRunModalComponent implements OnInit, OnDestroy { private _analysisService = inject(AnalysisService) + private _configService = inject(ConfigService) private _dialogRef = inject(MatDialogRef) private _cycleService = inject(CycleService) private _userService = inject(UserService) @@ -66,6 +67,9 @@ export class AnalysisRunModalComponent implements OnInit, OnDestroy { }) ngOnInit(): void { + this._configService.config$.pipe(takeUntil(this._unsubscribeAll$)).subscribe(({ integrations }) => { + if (!integrations.better) this.serviceTypes = this.serviceTypes.filter(({ value }) => value !== 'BETTER') + }) this.getCurrentUser() this.getCycles() this.getAnalyses() diff --git a/src/app/modules/main/home/home.component.html b/src/app/modules/main/home/home.component.html index 2c88a6fe..e25036c5 100644 --- a/src/app/modules/main/home/home.component.html +++ b/src/app/modules/main/home/home.component.html @@ -1,6 +1,74 @@
-
-

{{ t('The SEED Platform™') }}

-

{{ t('SEED_SUMMARY') }}

-
+ @if (config$ | async; as config) { + @if (config.branding.home_content_mode === 'custom') { + @if (config.branding.home_hero_image_url) { + + } +
+

{{ config.branding.home_heading || t('The SEED Platform™') }}

+

{{ config.branding.home_text }}

+
+ } @else { + +
+
+

{{ t('The SEED Platform™') }}

+

{{ t('SEED_SUMMARY') }}

+ +
+
+
+ +

{{ t('Upload your data') }}

+
+

{{ t('MARKETING_BULLET_1') }}

+
+ +
+
+ +

{{ t('Match your data') }}

+
+

{{ t('MARKETING_BULLET_2') }}

+
+ +
+
+ +

{{ t('Manage compliance') }}

+
+

{{ t('MARKETING_BULLET_3') }}

+
+
+
+
+ + +
+

{{ t('Getting Started') }}

+ + +
+ } + }
diff --git a/src/app/modules/main/home/home.component.ts b/src/app/modules/main/home/home.component.ts index 4d2aeabe..15f6c652 100644 --- a/src/app/modules/main/home/home.component.ts +++ b/src/app/modules/main/home/home.component.ts @@ -1,10 +1,33 @@ -import { Component, ViewEncapsulation } from '@angular/core' +import { AsyncPipe } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewEncapsulation } from '@angular/core' +import { RouterLink } from '@angular/router' +import { Subject, takeUntil } from 'rxjs' +import type { CurrentUser } from '@seed/api' +import { ConfigService, UserService } from '@seed/api' import { SharedImports } from '@seed/directives' +import { MaterialImports } from '@seed/materials' @Component({ selector: 'seed-home', templateUrl: './home.component.html', encapsulation: ViewEncapsulation.None, - imports: [SharedImports], + imports: [AsyncPipe, MaterialImports, RouterLink, SharedImports], }) -export class HomeComponent {} +export class HomeComponent implements OnDestroy, OnInit { + private _userService = inject(UserService) + private readonly _unsubscribeAll$ = new Subject() + readonly config$ = inject(ConfigService).config$ + currentUser: CurrentUser + + ngOnInit(): void { + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/organizations/settings/api-keys/api-keys.component.html b/src/app/modules/organizations/settings/api-keys/api-keys.component.html index 70374190..8d9ef9f2 100644 --- a/src/app/modules/organizations/settings/api-keys/api-keys.component.html +++ b/src/app/modules/organizations/settings/api-keys/api-keys.component.html @@ -12,31 +12,33 @@ -
-
- Your BETTER API key will need to be verified before it can be saved. Please paste your key below and then click the 'Verify - Token' button. + @if (betterEnabled) { +
+
+ Your BETTER API key will need to be verified before it can be saved. Please paste your key below and then click the 'Verify + Token' button. +
+ + BETTER Analysis API Key + + @if (apiKeyForm.controls.better_analysis_api_key.touched && apiKeyForm.controls.better_analysis_api_key?.valid) { + + } + @if (apiKeyForm.controls.better_analysis_api_key.touched && apiKeyForm.controls.better_analysis_api_key?.invalid) { + + } + @if (apiKeyForm.controls.better_analysis_api_key.touched && apiKeyForm.controls.better_analysis_api_key?.invalid) { + API Key is Invalid + } + +
+ An API key will be assigned to your account upon creation of a BETTER account at + https://better-lbnl-staging.herokuapp.com/accounts/signup/ +
- - BETTER Analysis API Key - - @if (apiKeyForm.controls.better_analysis_api_key.touched && apiKeyForm.controls.better_analysis_api_key?.valid) { - - } - @if (apiKeyForm.controls.better_analysis_api_key.touched && apiKeyForm.controls.better_analysis_api_key?.invalid) { - - } - @if (apiKeyForm.controls.better_analysis_api_key.touched && apiKeyForm.controls.better_analysis_api_key?.invalid) { - API Key is Invalid - } - -
- An API key will be assigned to your account upon creation of a BETTER account at - https://better-lbnl-staging.herokuapp.com/accounts/signup/ -
-
+ }