From 33fc7fff01a27771178730c9e466f8a1749a9081 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 19 Mar 2026 12:22:48 +0100 Subject: [PATCH 1/4] unify report email and pdf download and add inesco logo --- csharp/App/Backend/Controller.cs | 85 +++++++++++++ .../Backend/Services/ReportEmailService.cs | 113 +++++++++++++++++- .../images/inesco_logo_for_dark_bg.svg | 1 + .../SodiohomeInstallations/DailySection.tsx | 11 +- .../SodiohomeInstallations/WeeklyReport.tsx | 95 ++++++++++++--- 5 files changed, 281 insertions(+), 24 deletions(-) create mode 100644 typescript/frontend-marios2/src/Resources/images/inesco_logo_for_dark_bg.svg diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 5f38d6266..69d76a324 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -1471,6 +1471,91 @@ public class Controller : ControllerBase } } + // ── Report HTML (for PDF download) ───────────────────────────── + + [HttpGet(nameof(GetWeeklyReportHtml))] + public async Task GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); + + var lang = language ?? user.Language ?? "en"; + var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang); + var html = ReportEmailService.BuildHtmlEmail(report, lang); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetMonthlyReportHtml))] + public async Task GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); + + var lang = language ?? user.Language ?? "en"; + var report = Db.GetMonthlyReports(installationId).FirstOrDefault(r => r.Year == year && r.Month == month); + if (report == null) return BadRequest($"No monthly report found for {year}-{month:D2}."); + + report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang); + var s = ReportEmailService.GetAggregatedStrings(lang, "monthly"); + var html = ReportEmailService.BuildAggregatedHtmlEmail( + report.PeriodStart, report.PeriodEnd, installation.Name, + report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, + report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, + $"{report.WeekCount} {s.CountLabel}", s); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetYearlyReportHtml))] + public async Task GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); + + var lang = language ?? user.Language ?? "en"; + var report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year); + if (report == null) return BadRequest($"No yearly report found for {year}."); + + report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang); + var s = ReportEmailService.GetAggregatedStrings(lang, "yearly"); + var html = ReportEmailService.BuildAggregatedHtmlEmail( + report.PeriodStart, report.PeriodEnd, installation.Name, + report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, + report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, + $"{report.MonthCount} {s.CountLabel}", s); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetDailyReportHtml))] + public ActionResult GetDailyReportHtml(Int64 installationId, String date, Token authToken, String? language = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); + + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", out var parsedDate)) + return BadRequest("date must be in yyyy-MM-dd format."); + + var records = Db.GetDailyRecords(installationId, parsedDate, parsedDate); + if (records.Count == 0) return BadRequest($"No daily record found for {date}."); + + var lang = language ?? user.Language ?? "en"; + var html = ReportEmailService.BuildDailyHtmlEmail(records[0], installation.Name, lang); + return Content(html, "text/html"); + } + [HttpGet(nameof(GetWeeklyReportSummaries))] public async Task>> GetWeeklyReportSummaries( Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null) diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index e484a7501..d0ef5c7bd 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -9,6 +9,11 @@ namespace InnovEnergy.App.Backend.Services; public static class ReportEmailService { + // inesco logo (dark background variant, SVG) embedded as base64 data URI for emails and PDF reports + private const string LogoBase64 = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJf0KHQu9C+0LlfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgNjY2LjMzIDIzNy44MyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7fS5jbHMtMntmaWxsOiMwMGIwNTA7fTwvc3R5bGU+PC9kZWZzPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0ibTUuMDksMzIuMjJDMS43LDI4LjgzLDAsMjQuMzEsMCwxOC42NVMxLjcsOC40OCw1LjA5LDUuMDlDOC40OCwxLjcsMTMuMjQsMCwxOS4zOCwwczEwLjY2LDEuNywxNC4wNSw1LjA5YzMuMzksMy4zOSw1LjA5LDcuOTEsNS4wOSwxMy41NnMtMS43LDEwLjE3LTUuMDksMTMuNTdjLTMuMzksMy4zOS04LjA4LDUuMDktMTQuMDUsNS4wOXMtMTAuOS0xLjctMTQuMjktNS4wOVoiLz48cmVjdCBjbGFzcz0iY2xzLTEiIHg9IjIuNjYiIHk9IjUzLjc4IiB3aWR0aD0iMzMuNDMiIGhlaWdodD0iMTIzLjc4Ii8+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJtNjMuMTcsMTc3LjU2VjYxLjUzYzE2LjYzLTcuMTEsMzQuODgtMTAuNjYsNTQuNzUtMTAuNjYsMTcuNzYsMCwzMS40OSw0LjI4LDQxLjE4LDEyLjg0LDkuNjksOC41NiwxNC41NCwyMC44MywxNC41NCwzNi44MnY3Ny4wM2gtMzMuNDN2LTc0LjYxYzAtMTYuMzEtNy41MS0yNC40Ny0yMi41My0yNC40Ny03LjkxLDAtMTUuMDIsMS40NS0yMS4zMiw0LjM2djk0LjcyaC0zMy4xOVoiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Im0zMDUuNDEsMTA1Ljg2YzAsNS44MS0uMzMsMTEuOTUtLjk3LDE4LjQxaC03Ni41NWMzLjA3LDE4Ljc0LDE0LjM3LDI4LjEsMzMuOTEsMjguMSwxMS4zLDAsMjIuNTMtMi4zNCwzMy42Ny03LjAzbDUuMzMsMjYuODljLTEyLjExLDUuNDktMjYuMjUsOC4yNC00Mi4zOSw4LjI0LTE5LjcsMC0zNS41Ny01Ljg1LTQ3LjYtMTcuNTYtMTIuMDMtMTEuNzEtMTguMDUtMjcuNDktMTguMDUtNDcuMzZzNS41Ny0zNC44LDE2LjcxLTQ2Ljc1YzExLjE0LTExLjk1LDI1LjU5LTE3LjkyLDQzLjM2LTE3LjkyLDE1LjgyLDAsMjguNTQsNS4wOSwzOC4xNSwxNS4yNiw5LjYxLDEwLjE3LDE0LjQxLDIzLjQyLDE0LjQxLDM5LjczWm0tMzEuOTgtMS45NHYtMi45MWMwLTYuOTQtMS44Mi0xMi42LTUuNDUtMTYuOTYtMy42My00LjM2LTguNjgtNi41NC0xNS4xNC02LjU0LTYuOTUsMC0xMi42NCwyLjM0LTE3LjA4LDcuMDItNC40NCw0LjY5LTcuMjMsMTEuMTQtOC4zNiwxOS4zOGg0Ni4wM1oiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Im00MDMuNTYsODUuMDJjLTE0LjM3LTQuMzYtMjUuNi02LjU0LTMzLjY3LTYuNTQtMTEuMywwLTE2Ljk2LDMuMzEtMTYuOTYsOS45MywwLDMuMzksMS4zNyw1LjkzLDQuMTIsNy42MywyLjc0LDEuNyw4LjA3LDMuNjcsMTUuOTksNS45MywxNC42OSw0LjA0LDI1LjIzLDguOTIsMzEuNjEsMTQuNjYsNi4zOCw1Ljc0LDkuNTcsMTQuMDEsOS41NywyNC44MywwLDEyLjYtNC42NCwyMi4yNS0xMy45MywyOC45NS05LjI5LDYuNy0yMS42LDEwLjA1LTM2Ljk0LDEwLjA1LTE2Ljk2LDAtMzEuNTctMi42Ny00My44NS04bDQuNi0yNy4zN2MxMy40LDUuMTcsMjYuMjQsNy43NSwzOC41Miw3Ljc1LDUuNjUsMCwxMC4yMS0uOTMsMTMuNjktMi43OSwzLjQ3LTEuODUsNS4yMS00LjQ4LDUuMjEtNy44NywwLTMuNzEtMS42Ni02LjUtNC45Ny04LjM2LTMuMzEtMS44NS0xMC4yOS00LjMyLTIwLjk1LTcuMzktMTEuNzktMy4wNy0yMC42Ny03LjUxLTI2LjY1LTEzLjMyLTUuOTgtNS44MS04Ljk2LTEzLjgxLTguOTYtMjMuOTgsMC0xMi4yNyw0LjE2LTIxLjcyLDEyLjQ4LTI4LjM0LDguMzItNi42MiwxOS45LTkuOTMsMzQuNzYtOS45M3MyOC4xOCwyLjM0LDM5Ljk3LDcuMDJsLTMuNjMsMjcuMTNaIi8+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJtNTIzLjYzLDU4LjE0bC02LjMsMjYuNGMtOS41My00LjAzLTE4LjY1LTYuMDYtMjcuMzctNi4wNi05LjY5LDAtMTcuMTYsMy4yMy0yMi40MSw5LjY5LTUuMjUsNi40Ni03Ljg3LDE1LjQyLTcuODcsMjYuODksMCwyNS4xOSwxMC42NiwzNy43OSwzMS45OCwzNy43OSw4LjU2LDAsMTcuNTItMi4xLDI2Ljg5LTYuM2w1LjMzLDI2Ljg5Yy05LjM3LDQuNjgtMjEsNy4wMy0zNC44OCw3LjAzLTIwLjAzLDAtMzUuNjEtNS43Ny00Ni43NS0xNy4zMi0xMS4xNC0xMS41NC0xNi43MS0yNy4yNS0xNi43MS00Ny4xMnM1LjQ5LTM1LjY5LDE2LjQ3LTQ3LjQ4YzEwLjk4LTExLjc5LDI2LTE3LjY4LDQ1LjA2LTE3LjY4LDEzLjg5LDAsMjYuMDgsMi40MiwzNi41OCw3LjI3WiIvPjxwYXRoIGNsYXNzPSJjbHMtMSIgZD0ibTYxOS4yNiwxMzkuNTRjLTEyLjQ1LDkuNDQtMzAuMjcsOC40OS00MS42My0yLjg3LTExLjM2LTExLjM2LTEyLjMxLTI5LjE3LTIuODctNDEuNjNsLTI0LjUtMjQuNWMtMjIuODUsMjYuMDMtMjEuODcsNjUuNjksMi45OCw5MC41MywyNC44NCwyNC44NCw2NC41LDI1LjgzLDkwLjUzLDIuOThsLTI0LjUxLTI0LjUxWiIvPjxyZWN0IGNsYXNzPSJjbHMtMiIgeD0iNTYzLjI1IiB5PSI2Mi4yNyIgd2lkdGg9IjQuMzciIGhlaWdodD0iMzQuNTEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwOS4zNyA0MjMuMTEpIHJvdGF0ZSgtNDUpIi8+PHJlY3QgY2xhc3M9ImNscy0yIiB4PSI1NjkuOCIgeT0iNTYuNzgiIHdpZHRoPSI0LjM3IiBoZWlnaHQ9IjM0LjUxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2MS4wMSAzNDEuNTMpIHJvdGF0ZSgtMzUuMDEpIi8+PHJlY3QgY2xhc3M9ImNscy0yIiB4PSI1NzcuMiIgeT0iNTIuNTEiIHdpZHRoPSI0LjM3IiBoZWlnaHQ9IjM0LjUxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyNC44NyAyNTEuNjEpIHJvdGF0ZSgtMjUuMDIpIi8+PHJlY3QgY2xhc3M9ImNscy0yIiB4PSI1ODUuMjMiIHk9IjQ5LjU5IiB3aWR0aD0iNC4zNyIgaGVpZ2h0PSIzNC41MSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMi43MyAxNTQuNDIpIHJvdGF0ZSgtMTUuMDEpIi8+PHJlY3QgY2xhc3M9ImNscy0yIiB4PSI1OTMuNjUiIHk9IjQ4LjEiIHdpZHRoPSI0LjM3IiBoZWlnaHQ9IjM0LjUxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMy40MyA1Mi4yMSkgcm90YXRlKC01KSIvPjxyZWN0IGNsYXNzPSJjbHMtMiIgeD0iNTg3LjEyIiB5PSI2My4xNyIgd2lkdGg9IjM0LjUxIiBoZWlnaHQ9IjQuMzciIHRyYW5zZm9ybT0idHJhbnNsYXRlKDQ4Ni41NSA2NjEuNzMpIHJvdGF0ZSgtODUpIi8+PHJlY3QgY2xhc3M9ImNscy0yIiB4PSI1OTUuNTQiIHk9IjY0LjY2IiB3aWR0aD0iMzQuNTEiIGhlaWdodD0iNC4zNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzg5LjY0IDY0MS40Nikgcm90YXRlKC03NSkiLz48cmVjdCBjbGFzcz0iY2xzLTIiIHg9IjYwMy41NyIgeT0iNjcuNTgiIHdpZHRoPSIzNC41MSIgaGVpZ2h0PSI0LjM3IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyOTUuMjkgNjAyLjk4KSByb3RhdGUoLTY1LjAxKSIvPjxyZWN0IGNsYXNzPSJjbHMtMiIgeD0iNjEwLjk3IiB5PSI3MS44NSIgd2lkdGg9IjM0LjUxIiBoZWlnaHQ9IjQuMzciIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIwNy4zIDU0Ni4yMykgcm90YXRlKC01NS4wMSkiLz48cmVjdCBjbGFzcz0iY2xzLTIiIHg9IjYxNy41MiIgeT0iNzcuMzUiIHdpZHRoPSIzNC41MSIgaGVpZ2h0PSI0LjM3IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjkuNjEgNDcyLjA1KSByb3RhdGUoLTQ0Ljk5KSIvPjxyZWN0IGNsYXNzPSJjbHMtMiIgeD0iNjIzLjAxIiB5PSI4My44OSIgd2lkdGg9IjM0LjUxIiBoZWlnaHQ9IjQuMzciIHRyYW5zZm9ybT0idHJhbnNsYXRlKDY2LjM0IDM4Mi42NSkgcm90YXRlKC0zNC45OCkiLz48cmVjdCBjbGFzcz0iY2xzLTIiIHg9IjYyNy4yOCIgeT0iOTEuMjkiIHdpZHRoPSIzNC41MSIgaGVpZ2h0PSI0LjM2IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMC45MyAyODEuMykgcm90YXRlKC0yNS4wMSkiLz48cmVjdCBjbGFzcz0iY2xzLTIiIHg9IjYzMC4yMSIgeT0iOTkuMzMiIHdpZHRoPSIzNC41MSIgaGVpZ2h0PSI0LjM2IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNC4yMSAxNzEuMDgpIHJvdGF0ZSgtMTUpIi8+PHJlY3QgY2xhc3M9ImNscy0yIiB4PSI2MzEuNjkiIHk9IjEwNy43NCIgd2lkdGg9IjM0LjUxIiBoZWlnaHQ9IjQuMzciIHRyYW5zZm9ybT0idHJhbnNsYXRlKC03LjEgNTYuODYpIHJvdGF0ZSgtNC45OSkiLz48cmVjdCBjbGFzcz0iY2xzLTIiIHg9IjY0Ni43NiIgeT0iMTAxLjIyIiB3aWR0aD0iNC4zNyIgaGVpZ2h0PSIzNC41MSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNDc0LjQ3IDc1NC42NSkgcm90YXRlKC04NS4wMSkiLz48cmVjdCBjbGFzcz0iY2xzLTIiIHg9IjY0NS4yOCIgeT0iMTA5LjYzIiB3aWR0aD0iNC4zNyIgaGVpZ2h0PSIzNC41MSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzU3LjMgNzE5LjQzKSByb3RhdGUoLTc1KSIvPjxyZWN0IGNsYXNzPSJjbHMtMiIgeD0iNjQyLjM2IiB5PSIxMTcuNjYiIHdpZHRoPSI0LjM2IiBoZWlnaHQ9IjM0LjUxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyNDkuNzIgNjYxLjk0KSByb3RhdGUoLTY0Ljk4KSIvPjxwb2x5Z29uIGNsYXNzPSJjbHMtMiIgcG9pbnRzPSI2MjcuMzkgMTMwLjYzIDYyNC44OCAxMzQuMjEgNjUzLjE1IDE1NCA2NTUuNjUgMTUwLjQzIDYyNy4zOSAxMzAuNjMiLz48cG9seWdvbiBjbGFzcz0iY2xzLTIiIHBvaW50cz0iNjI0LjEyIDEzNS4xMiA2MjEuMDMgMTM4LjIxIDY0NS40MyAxNjIuNjEgNjQ4LjUxIDE1OS41MyA2MjQuMTIgMTM1LjEyIi8+PHBvbHlnb24gY2xhc3M9ImNscy0xIiBwb2ludHM9IjkuMTEgMjI0LjY3IDIzLjc4IDIyNC42NyAyMy43OCAyMTcuNzkgOS4xMSAyMTcuNzkgOS4xMSAyMTIuODEgMzQuMjggMjEyLjgxIDM0LjI4IDIwNS43OCAwIDIwNS43OCAwIDIzNy44MyAzNC42NCAyMzcuODMgMzQuNjQgMjMwLjU5IDkuMTEgMjMwLjU5IDkuMTEgMjI0LjY3Ii8+PHBvbHlnb24gY2xhc3M9ImNscy0xIiBwb2ludHM9IjE1Mi4xNiAyMjUuMTIgMTI3LjQ1IDIwNS43OCAxMjEuMjUgMjA1Ljc4IDEyMS4yNSAyMzcuODMgMTI5LjU3IDIzNy44MyAxMjkuNTcgMjE4LjIxIDE1NC4wNCAyMzcuNjUgMTU0LjI2IDIzNy44MyAxNjAuNDggMjM3LjgzIDE2MC40OCAyMDUuNzggMTUyLjE2IDIwNS43OCAxNTIuMTYgMjI1LjEyIi8+PHBvbHlnb24gY2xhc3M9ImNscy0xIiBwb2ludHM9IjI1Ni4yMSAyMjQuNjcgMjcwLjg3IDIyNC42NyAyNzAuODcgMjE3Ljc5IDI1Ni4yMSAyMTcuNzkgMjU2LjIxIDIxMi44MSAyODEuMzcgMjEyLjgxIDI4MS4zNyAyMDUuNzggMjQ3LjEgMjA1Ljc4IDI0Ny4xIDIzNy44MyAyODEuNzMgMjM3LjgzIDI4MS43MyAyMzAuNTkgMjU2LjIxIDIzMC41OSAyNTYuMjEgMjI0LjY3Ii8+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJtMzk2LjU1LDIyNi4zMmMyLjY2LDAsNC41OC0uNDksNS44OS0xLjUsMS4zNi0xLjA0LDIuMDQtMi43OCwyLjA0LTUuMTR2LTcuMTljMC0yLjQxLS42OS00LjE3LTIuMDQtNS4yMS0xLjMxLTEuMDEtMy4yNC0xLjUtNS44OS0xLjVoLTI4LjIxdjMyLjA1aDguOTd2LTExLjVoNS44NGwxMi43MywxMS41aDEzLjI5bC0xNC40NS0xMS41aDEuODNabS0xOS4yNC0xMy42NmgxNi4wOGMxLjE5LDAsMS44Mi4yLDIuMTMuMzcuMjkuMTYuNDQuNTguNDQsMS4yNnYzLjUyYzAsLjY4LS4xNSwxLjEtLjQ0LDEuMjYtLjMxLjE3LS45My4zNy0yLjEzLjM3aC0xNi4wOHYtNi43OFoiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Im01MzMuMTIsMjA3LjMxYy0xLjM2LTEuMDMtMy41Mi0xLjUzLTYuNjEtMS41M2gtMjJjLTEuNTMsMC0yLjg1LjExLTMuOTEuMzQtMS4xNC4yNC0yLjA4LjY4LTIuOCwxLjI5LS43NS42My0xLjI4LDEuNTEtMS41OSwyLjU5LS4yOSwxLjAxLS40MywyLjI1LS40MywzLjc4djE2LjAzYzAsMS41My4xNCwyLjc2LjQzLDMuNzUuMywxLjA1LjgyLDEuOTIsMS41MiwyLjU3LjcxLjY1LDEuNjUsMS4xLDIuNzksMS4zNSwxLjA2LjIzLDIuNC4zNCwzLjk4LjM0aDIyYzEuNTMsMCwyLjg2LS4xMSwzLjk0LS4zNCwxLjE3LS4yNCwyLjEyLS43LDIuODMtMS4zNS43MS0uNjUsMS4yMi0xLjUyLDEuNTItMi41Ny4yOC0uOTguNDMtMi4yNC40My0zLjc1di0xMS4zMWgtMjAuOTd2Ny4wM2gxMi4wOHY0Ljg0aC0yMS41MnYtMTcuMzVoMjEuNTJ2My4zOGw4Ljg5LTEuNXYtMS40OGMwLTMuMDMtLjY5LTUuMDMtMi4xMi02LjExWiIvPjxwb2x5Z29uIGNsYXNzPSJjbHMtMSIgcG9pbnRzPSI2NTUuMzIgMjA1Ljc4IDY0NC42NiAyMTYuNzggNjM0LjE1IDIwNS43OCA2MjEuODUgMjA1Ljc4IDYzOS42IDIyMy42OSA2MzkuNiAyMzcuODMgNjQ4LjY0IDIzNy44MyA2NDguNjQgMjIzLjU0IDY2Ni4zMyAyMDUuNzggNjU1LjMyIDIwNS43OCIvPjwvc3ZnPg=="; + + // NOTE: old PNG constant removed — using SVG dark-bg variant (data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAACECAYAAADhnvK8AAAx/UlEQVR4Xu99B3xU6XWv7cRJHLes7dgvrnF7ifPecxw/97zEz7G3eNdrpS29twUhOkh0hCiiCERdQKIXUQXqHaGCGgjUu1BBAgkk1FAvnHz/I2aR7oymXM0dDTvf379jFqG58917v/u/p59PkISEhISD4hPKH0hISEg4CiQBSkhIOCwkAUpISDgsJAFKSEg4LCQBSkhIOCwkAUpISDgsJAFKSEg4LCQBSkhIOCwkAUpISDgsJAFKSEg4LCQBSkhIOCwkAUpISDgsJAFKSEg4LCQBSkhIOCwkAUpISDgsNCfAnue91NjVQpVttSzPutupl54rf01CQkLC5tCEALuf91Dhs4e0uySA3k3eQj+KdqbvRMxh+X7kPPpVnCttKbxCRS3V1PtckqGEhMTwwOoE2NLTTpeqbtG/3VxGnw4YTZ/yH0mfuD5igHzy+kj6S/FvXwmbQucr44U+KElQQkLC9rAqAbb3dtG1Ryn0zfBZeqQ3mIAgoQ32ClNZQkJCwpawGgFCi8trrqSfxa7UIzmj4v8evRYyiZLrC5WHlJCQkNAUViPAtp5O8iz2p08aMHlNiv8ImnHvAAdMJCQkJGwFqxFgQ1cLvZu8VZ/czJT/dWMhPe18pjyshISEhGawGgHWdDTQdyPn6hGbufL5oPFU1vpYeVgJCQkJzWA1AnzY/pS+YUHwQyl/GziWilseKQ8rISEhoRmsRoCPOxrpe1EfsD9PSW7myJdCJlG51AAlJCRsCKsRIHyAf0rerEds5sl79E/RTlTdUa88rISEhIRmsBoBIgq8q/g6p7XoE5wJEVqjU8ZhmQsoISFhU1iNAJEHWNTykP7t5lJ9gjMhMH+T62QeoISEhG1hNQIEOnq76ToqQSJm0af8R+kRnSH5YvBE2ljgK7U/CQkJm8OqBAig24tvVRz9Jn4VvRYymf7CABGi/O0LwRM498+rJICau9ugQkpISEjYFFYnQADdYHKbK8ijyI/GpXnS726tp1/ErqSfxa6g38S50shUD9qQ70t3G+5TT6/U/CQkJIYHmhCgDvALtvR0UElLNaXWF1FyfQFlNJZSfVeL7AAjISEx7NCUACUkJCTsGZIAJSQkHBaSACUkJEzgpbuqv+MKbqxX3ZElCVBCQkIPyMxo7+3k/wbNeRRdpV/Hu1L443T+WUdvF7kXXKJRqdvpXuN9/hn8/fVdr1ZHJ4chwM6uLqqta6Ci+w/oXlYB3Ui4TReuR9ChE1do9+Fz5LH/FG3xOkabdh8jj32nxc/O0+nLIRQclUC3UjOopOwBtba1Kw9rp3hO9fWNVFhSQSlp2eTrF0ZeR31FeZ0U53ictu09QV5HztN58fOIm8mUmVNMz5610nM7mc/S3NJCZQ+qKD2nkEKib9HhU1dp58MW+zmP9vZOelj9hDJziygiNoWO+QaQ54fnxHmcpM07xH3wOkG7Dp6lI+L8LgdEUXTcbbqTnkePntRRd0+38nA2Q0jNXfp53Eqak36IylufEPbT0uwT3LBkb3EQ/w7S3ZDq9sOo+ZTX/IB/Fl+XQ78Un3PO9KbajsZ+R7RfWJUA29vbxUZMp+uhNy2Sa0Iam1qUh7MY3T091NbeQU3NLVRa/pBuxN8m7zPXadWWAzR1oRu9PXEJ/WHMAlXy5tiFNHL6SlrhtpfOXA6mvKJSanrWQl1d3TTcz1uPOG+Qc2FpBV/PlZv20YhpK/TOwZjozs3XL5wqHz0Wx2uj3l7tT6y3t5faOzroQVU1k7G7pw9NmLeW3hjrrLfGP4xxMvn3dycvo0lO65nsL/pHUoYg0abmZ7wvsD+0Ap+H+I6qh4953+H7Zy7eTG+NW2TgPIyJE709YTGNm7OG1m07ROeuhlFu4X3xfDyjjo5Oqw8RQxPispbHdKEygZKe5rO2d1dodL9P3MBVXakvOrUfKQunL4ZMpBXZJ/nvTd2tXPDwz9HOVNVexxrhyYpoHoexKvcM/3u3OHZoTRqFPb7Hf7dHWJUAa+vqael6LwM31bRUPrS8Ewze9O1iU9Q+baDi0gf8AG0Rb1ZsvDfeN/QAWUP6Hjps0nkrPVgDKSmvolax+W0NaLXVj+tYm1295SD9Uaypb31KYjBHXnxutBP9SZDIhh2H6U5GLtU3NCm/1ipA/mejIKa7WflCKzpLo2e49FuHcm2Wiu4Yfec0ZuYrG7E5u92J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRNcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRNcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJcKkdZ3NHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRNcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJcKkdZ3NHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJcKkdZ3NHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/BXhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJcKkdZ3NHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDAY3hba2Dg60rHTfp4FPcHBBU4OVm/ZSlokmESl3c3juivLzpmQ4CRA9AjH3WLkmU/LOxKW05/B5s+ujhwIMWUKN709iltBv4l15FslbiZtoapoXD1vfXHCJ9gtt73hFFHeIxhzhvOYHPH+ko1cb8xeQBKgCWhHgNUEiryIBoioGmp2580/wwOF+n7oYzJUdSIH5m6Ax9Auh+WFAOnJBNbvmaFmFFlro/LIg8wh9L2ou9xL8IP0wd4o5Vh7FZPuPEXO5HA85iJ4l/jwmF0EQRIfB6D3iT2usxRgkAVoI+AIPHr9ssZaEVAr0c0NlhzEg9UX5WdPiRGu2HaKnVujdh7SVucu3GviOwQVdXSJikoXJqq+x1Tc2v2h5pf85YwLfGwJX6Nrd14rLdoSCgBcakirXZErgGjnvF85kZGtUVFVWcuVT5LuDxc3Q/Q/bIq5WGHBLS/Qp4g/H+HhQm8regqB1ZeC55Enw4YTb+KdaETFdHsA0TuH4IgFa1P6NrDZK5DRq4gmiqg+7RWkASoAogqWvqmhew6dMZEHedzYf7CvLbc/xcYFme1Rp8LV+808B1GZPQC8g+NM9ijDj9COoreZ8wUaCgjpq3kEjZr9PEzB/uQQ2fhCw4yY/EmLpkzFazQAhjzgACYck3myKT567iZsBZAGywkSH8/6gOuIvl80ASuNY6uzeCI75OOJtYK0ZoLM0c+FzyOPuU/gj4p5Avid11zTnMvQa0gCVAFYGogv0v5HabEFAEiTcZSHw4EZIzZH32Rwt4hC5LDza2u0Ak0tYE5bn1AZPLYOX9VmolSkNqBhqZILbmdnsM5c/hORHatRTmt4nibdx97kfysvwZjAr+hpZ1arAHcMzTHfU9F0AbXdJOnN6d6aYPnnC4DM/edlM1cfYIkaO/ySPYRfjfiAy7P+6wwhb8SOoX7ByIpG5pjwbMqNqW1hCRAFUC1iJoIoSkCRJqMGtML1RLwLyamZlLynawhC6pHlN9hSqAVI9VECWhDaOD6suW9dQQPLipRkPOGHEZcuyd19UOOHD+urWdz0NKkdxA8gh/WSnuxBIje+5y7bnH0HoL9FhKVoDykVdHa08mNESbc8eSE6r8OGENfDJ7A/QJ/Ikzi92/vYlP3XmMp1wrrgPQa1AZrOXxTEqAKnLsSqgkBJqSmq0qxeSm6kQDWEOWxjQuGTzUMkmSMc8ZEN2togQOlb60wk8fOWU0bdh6l4Khb3HoLpKAmEIHGDfNdthv4LuMCkxlasCkfrxaoefKUnwk1923Osq1m56cOBduKrtBPY5Zyd5kxqTs4DeZcZSx3lq5sq6X8Z5UcBEmpL6T4ulwev3nlYSL5VsVxuZxuhom1IQlQBbQiQDQMHY428daQ4+cDeMiRIaDcEJUpatKHLBMnLnmc77qdDp/2Ey+UDIuHOmXlFzMp6B/buKB+F+Q7HKk7qG9GpYilBAgt2nXzAR5ToDVS64toUdYxFtT+rs09R0uzTpBzhjdNFybv28nu9Ks4V+5E8/3IefQlYQ5/OnA0R4mRV9jWo+9ftgYkAaqAVgQIU+7NV5QAz14NNXpuCIbA0Y7GBsrPaiF4uCc7beC9glI9c4kpLSOPZi7ZrHc8UzJziTunApnbq8+aeFhdSx77LC+dhLWBJHZbaK0obUOiM3oAoiECGrGiHdb/vrGIS+jeTHTjxqiz7x3i8juXnDOckO11P5Aym8r6UmM0gCRAFdCKAP2CYozUcNq3oM+eqWFQCFggxQRkYam2olbgF5u52J1TkCqqTM8zQf2xmq7PiMDeEeQ5HED9L7rJKNdkSpDKtfeoL/tptQZaYW3I9+VyO9Qco10WJs2h1A4lb6gPLm19TNUdDfS08xlPjdOK9PpDEqAKaEWAlwOjXlkCRPcSDcb7xXVt7J3XfpYPKzJsd/h8kNQeJz6j6OFoefCZqa4PWI1v3n6K2tg7+X0dHJ8Uk3eWWavYGa+w7U4L0HoWlFRwUg6qhGbuPWNWYhHeA+z3SdUd2Uu3YrJdlNBqAkQKCTYTfIEPHtsHVsMvFRLwLoiC8v7Mak/aEh1eBYzjIVq/u5e1t13nTjRnbVCbGtF2QZu9iMXbOJprsdNyp4F3hPDOvO/QG38Yn0IjN+zizOBH+jT1Bg2h6PJzYR3HhxCHQm8FKDvhtfaxqPjVxhVMHXYfpq6lST5uKafP1efPzYvGC6FmjS9lPjWqYj3Gt2VJWjobq6qbqZSpseU/nzNA0ooGhTa6+bXrk7pLf3Ukllt1lJLH5IAVcDuCVBCQkLCSpAEKCEh4bCQBCghIeGwkAQoISHhsJAEKCEh4bCQBCghIeGwkAQoISHhsJAEKCEh4bCQBCghIeGwkAQoISHhsJAEKCEh4bCQBCghIeGwkAQoISHhsJAEKCEh4bCQBCghIeGw+G/m/9/qNB0E2gAAAABJRU5ErkJggg=="; + /// /// Sends the weekly report as a nicely formatted HTML email in the user's language. /// Uses MailKit directly (same config as existing Mailer library) but with HTML support. @@ -16,7 +21,8 @@ public static class ReportEmailService public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en", string customerName = null) { var strings = GetStrings(language); - var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; + var installSegment = !string.IsNullOrWhiteSpace(report.InstallationName) ? $" — {report.InstallationName}" : ""; + var subject = $"{strings.Title}{installSegment} ({report.PeriodStart} to {report.PeriodEnd})"; var html = BuildHtmlEmail(report, strings, customerName); var config = await ReadMailerConfig(); @@ -334,6 +340,7 @@ public static class ReportEmailService +
{s.Title}
{r.InstallationName}
{r.PeriodStart} — {r.PeriodEnd}
@@ -467,7 +474,8 @@ public static class ReportEmailService var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn }; var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString(); var s = GetAggregatedStrings(language, "monthly"); - var subject = $"{s.Title} — {installationName} ({monthName} {report.Year})"; + var installSegment = !string.IsNullOrWhiteSpace(installationName) ? $" — {installationName}" : ""; + var subject = $"{s.Title}{installSegment} ({monthName} {report.Year})"; var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, @@ -485,7 +493,8 @@ public static class ReportEmailService string customerName = null) { var s = GetAggregatedStrings(language, "yearly"); - var subject = $"{s.Title} — {installationName} ({report.Year})"; + var installSegment = !string.IsNullOrWhiteSpace(installationName) ? $" — {installationName}" : ""; + var subject = $"{s.Title}{installSegment} ({report.Year})"; var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, @@ -520,7 +529,7 @@ public static class ReportEmailService // ── Aggregated report translation strings ───────────────────────────── - private record AggregatedEmailStrings( + public record AggregatedEmailStrings( string Title, string Insights, string Summary, string SavingsHeader, string Metric, string Total, string PvProduction, string Consumption, string GridImport, string GridExport, string BatteryInOut, @@ -530,7 +539,7 @@ public static class ReportEmailService string FooterLink ); - private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch + public static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch { ("de", "monthly") => new AggregatedEmailStrings( "Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat", @@ -592,7 +601,98 @@ public static class ReportEmailService // ── Aggregated HTML email template ──────────────────────────────────── - private static string BuildAggregatedHtmlEmail( + // ── Daily Report HTML ──────────────────────────────────────────── + + public static string BuildDailyHtmlEmail( + DailyEnergyRecord record, string installationName, string language = "en") + { + var s = GetAggregatedStrings(language, "monthly"); // reuse monthly strings for metric labels + + var dailyTitle = language switch + { + "de" => "Täglicher Energiebericht", + "fr" => "Rapport énergétique quotidien", + "it" => "Rapporto energetico giornaliero", + _ => "Daily Energy Report" + }; + + var selfSufficiency = record.LoadConsumption > 0 + ? Math.Max(0, (1 - record.GridImport / record.LoadConsumption)) * 100 + : 0; + var batteryEfficiency = record.BatteryCharged > 0 + ? Math.Min(100, record.BatteryDischarged / record.BatteryCharged * 100) + : 0; + var energySaved = Math.Max(0, record.LoadConsumption - record.GridImport); + var savingsCHF = energySaved * 0.39; + + return $@" + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
{dailyTitle}
+
{installationName}
+
{record.Date}
+
+
{s.Summary}
+ + + + + + + + + + +
{s.Metric}{s.Total}
{s.PvProduction}{record.PvProduction:F1} kWh
{s.Consumption}{record.LoadConsumption:F1} kWh
{s.GridImport}{record.GridImport:F1} kWh
{s.GridExport}{record.GridExport:F1} kWh
{s.BatteryInOut}{record.BatteryCharged:F1} / {record.BatteryDischarged:F1} kWh
+
+
{s.SavingsHeader}
+ + + {SavingsBox(s.SolarEnergyUsed, $"{energySaved:F1} kWh", s.StayedAtHome, "#27ae60")} + {SavingsBox(s.EstMoneySaved, $"~{savingsCHF:F0} CHF", s.AtRate, "#2980b9")} + {SavingsBox(s.SolarCoverage, $"{selfSufficiency:F0}%", s.FromSolar, "#8e44ad")} + {SavingsBox(s.BatteryEff, $"{batteryEfficiency:F0}%", s.OutVsIn, "#e67e22")} + +
+
+ {s.Footer} + +
+
+ +"; + } + + public static string BuildAggregatedHtmlEmail( string periodStart, string periodEnd, string installationName, double pvProduction, double consumption, double gridImport, double gridExport, double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF, @@ -623,6 +723,7 @@ public static class ReportEmailService +
{s.Title}
{installationName}
{periodStart} — {periodEnd}
diff --git a/typescript/frontend-marios2/src/Resources/images/inesco_logo_for_dark_bg.svg b/typescript/frontend-marios2/src/Resources/images/inesco_logo_for_dark_bg.svg new file mode 100644 index 000000000..c58778608 --- /dev/null +++ b/typescript/frontend-marios2/src/Resources/images/inesco_logo_for_dark_bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/DailySection.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/DailySection.tsx index e21c151e4..c1206734e 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/DailySection.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/DailySection.tsx @@ -97,10 +97,12 @@ function getCurrentWeekDays(currentMonday: Date): Date[] { export default function DailySection({ installationId, - onHasData + onHasData, + onPeriodChange }: { installationId: number; onHasData?: (hasData: boolean) => void; + onPeriodChange?: (date: string) => void; }) { const intl = useIntl(); const currentMonday = useMemo(() => getCurrentMonday(), []); @@ -113,7 +115,11 @@ export default function DailySection({ const [allRecords, setAllRecords] = useState([]); const [allHourlyRecords, setAllHourlyRecords] = useState([]); - const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday)); + const [selectedDate, setSelectedDate] = useState(() => { + const date = formatDateISO(yesterday); + onPeriodChange?.(date); + return date; + }); const [selectedDayRecord, setSelectedDayRecord] = useState(null); const [hourlyRecords, setHourlyRecords] = useState([]); const [loadingWeek, setLoadingWeek] = useState(false); @@ -174,6 +180,7 @@ export default function DailySection({ const handleStripSelect = (date: string) => { setSelectedDate(date); setNoData(false); + onPeriodChange?.(date); }; const dt = new Date(selectedDate + 'T00:00:00'); diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx index 326621352..f19929a26 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -236,6 +236,8 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { const [regenerating, setRegenerating] = useState(false); const [dailyHasData, setDailyHasData] = useState(false); const [weeklyHasData, setWeeklyHasData] = useState(false); + const [downloadingPdf, setDownloadingPdf] = useState(false); + const [reportPeriod, setReportPeriod] = useState<{ start: string; end: string; year?: number; month?: number } | null>(null); const weeklyRef = useRef(null); const fetchReportData = () => { @@ -302,16 +304,56 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { return false; })(); + const handleDownloadPdf = async () => { + const reportType = tabs[safeTab]?.key ?? 'report'; + let endpoint = ''; + const params: Record = { installationId, language: intl.locale }; + + switch (reportType) { + case 'daily': + endpoint = '/GetDailyReportHtml'; + if (reportPeriod?.start) params.date = reportPeriod.start; + break; + case 'weekly': + endpoint = '/GetWeeklyReportHtml'; + break; + case 'monthly': + endpoint = '/GetMonthlyReportHtml'; + if (reportPeriod?.year) params.year = reportPeriod.year; + if (reportPeriod?.month) params.month = reportPeriod.month; + break; + case 'yearly': + endpoint = '/GetYearlyReportHtml'; + if (reportPeriod?.year) params.year = reportPeriod.year; + break; + } + + if (!endpoint) return; + + setDownloadingPdf(true); + try { + const res = await axiosConfig.get(endpoint, { params, responseType: 'text' }); + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + const dateRange = reportPeriod + ? `${reportPeriod.start.replace(/-/g, '')}-${reportPeriod.end.replace(/-/g, '')}` + : new Date().toISOString().split('T')[0].replace(/-/g, ''); + + printWindow.document.write(res.data); + printWindow.document.close(); + printWindow.document.title = `inesco-energy-${installationId}-${reportType}-${dateRange}`; + printWindow.onafterprint = () => printWindow.close(); + setTimeout(() => printWindow.print(), 500); + } catch (err) { + console.error('PDF download failed', err); + } finally { + setDownloadingPdf(false); + } + }; + return ( - } - onClick={() => window.print()} + startIcon={downloadingPdf ? : } + onClick={handleDownloadPdf} + disabled={downloadingPdf} sx={{ ml: 2, whiteSpace: 'nowrap' }} > @@ -361,7 +404,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - + setReportPeriod({ start: date, end: date })} /> setReportPeriod({ start, end })} /> @@ -384,6 +428,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { onGenerate={handleGenerateMonthly} selectedIdx={selectedMonthlyIdx} onSelectedIdxChange={setSelectedMonthlyIdx} + onPeriodChange={(r: MonthlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month })} /> @@ -395,6 +440,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { onGenerate={handleGenerateYearly} selectedIdx={selectedYearlyIdx} onSelectedIdxChange={setSelectedYearlyIdx} + onPeriodChange={(r: YearlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year })} /> @@ -407,8 +453,8 @@ interface WeeklySectionHandle { regenerate: () => void; } -const WeeklySection = forwardRef void }>( - ({ installationId, latestMonthlyPeriodEnd, onHasData }, ref) => { +const WeeklySection = forwardRef void; onPeriodChange?: (start: string, end: string) => void }>( + ({ installationId, latestMonthlyPeriodEnd, onHasData, onPeriodChange }, ref) => { const intl = useIntl(); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); @@ -427,6 +473,7 @@ const WeeklySection = forwardRef void; selectedIdx: number; onSelectedIdxChange: (idx: number) => void; + onPeriodChange?: (report: MonthlyReport) => void; }) { const intl = useIntl(); @@ -871,6 +920,7 @@ function MonthlySection({ sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })} controlledIdx={selectedIdx} onIdxChange={onSelectedIdxChange} + onPeriodChange={onPeriodChange} /> ) : pendingMonths.length === 0 ? ( @@ -892,7 +942,8 @@ function YearlySection({ generating, onGenerate, selectedIdx, - onSelectedIdxChange + onSelectedIdxChange, + onPeriodChange }: { installationId: number; reports: YearlyReport[]; @@ -901,6 +952,7 @@ function YearlySection({ onGenerate: (year: number) => void; selectedIdx: number; onSelectedIdxChange: (idx: number) => void; + onPeriodChange?: (report: YearlyReport) => void; }) { const intl = useIntl(); @@ -952,6 +1004,7 @@ function YearlySection({ sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })} controlledIdx={selectedIdx} onIdxChange={onSelectedIdxChange} + onPeriodChange={onPeriodChange} /> ) : pendingYears.length === 0 ? ( @@ -975,7 +1028,8 @@ function AggregatedSection({ sendEndpoint, sendParamsFn, controlledIdx, - onIdxChange + onIdxChange, + onPeriodChange }: { reports: T[]; type: 'monthly' | 'yearly'; @@ -986,6 +1040,7 @@ function AggregatedSection({ sendParamsFn: (r: T) => object; controlledIdx?: number; onIdxChange?: (idx: number) => void; + onPeriodChange?: (report: T) => void; }) { const intl = useIntl(); const [internalIdx, setInternalIdx] = useState(0); @@ -993,8 +1048,16 @@ function AggregatedSection({ const handleIdxChange = (idx: number) => { setInternalIdx(idx); onIdxChange?.(idx); + if (reports[idx]) onPeriodChange?.(reports[idx]); }; + // Report initial period on mount + useEffect(() => { + if (reports.length > 0 && reports[selectedIdx]) { + onPeriodChange?.(reports[selectedIdx]); + } + }, [reports.length]); + if (reports.length === 0) { return ( From 2d612876c20da9def3470b42bb196a8eaa2f069c Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 19 Mar 2026 12:56:03 +0100 Subject: [PATCH 2/4] fixed always check battery priority setting when it's in other priority mode --- .../SodistoreHomeConfiguration.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 620a8f388..a6caf051b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -100,14 +100,16 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { clusterNumber: props.values.Config.ClusterNumber ?? 1, PvNumber: props.values.Config.PvNumber ?? 0, timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0, - startTimeChargeandDischargeDayandTime: - props.values.Config?.StartTimeChargeandDischargeDayandTime - ? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate() - : null, - stopTimeChargeandDischargeDayandTime: - props.values.Config?.StopTimeChargeandDischargeDayandTime - ? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate() - : null, + startTimeChargeandDischargeDayandTime: (() => { + const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime; + const parsed = raw ? dayjs(raw) : null; + return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date(); + })(), + stopTimeChargeandDischargeDayandTime: (() => { + const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime; + const parsed = raw ? dayjs(raw) : null; + return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date(); + })(), controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true", }); @@ -200,9 +202,11 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { }); }; -// Add time validation function +// Add time validation function — only relevant for Sinexcel BatteryPriority const validateTimeOnly = () => { - if (formValues.startTimeChargeandDischargeDayandTime && + if (device === 4 && + OperatingPriorityOptions[formValues.operatingPriority] === 'BatteryPriority' && + formValues.startTimeChargeandDischargeDayandTime && formValues.stopTimeChargeandDischargeDayandTime) { const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours(); const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes(); From 897f3137f5db87d7cfe0d557415f9b02d7c08dce Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 19 Mar 2026 13:34:37 +0100 Subject: [PATCH 3/4] allow multiple inverter SN regiter --- .../Information/InformationSodistoreHome.tsx | 156 +++++++++++++++--- 1 file changed, 136 insertions(+), 20 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index b7b8b6cfa..0b3308884 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx @@ -53,6 +53,50 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { [] ); + // Parse inverter/datalogger serial numbers from various legacy formats: + // Slash-separated: "SN001/SN002" + // Labeled comma: "Inverter 1: SN001, Inverter 2: SN002" + // Labeled comma: "Datalogger 1: SN001, Datalogger 2: SN002" + // Plain string: "SN001" + const parseSerialNumbers = (value: string | undefined): string[] => { + if (!value || value.trim() === '') return []; + + // Check for labeled comma format: "Inverter 1: SN001, Inverter 2: SN002" + if (/(?:Inverter|Datalogger)\s*\d+\s*:/i.test(value)) { + const matches = value.match(/(?:Inverter|Datalogger)\s*\d+\s*:\s*([^,]+)/gi); + if (matches) { + return matches + .map((m) => m.replace(/^(?:Inverter|Datalogger)\s*\d+\s*:\s*/i, '').trim()) + .filter((s) => s !== ''); + } + } + + // Slash-separated format: "SN001/SN002" + if (value.includes('/')) { + return value.split('/').filter((s) => s.trim() !== ''); + } + + // Single value + return [value.trim()]; + }; + + const [inverterNumber, setInverterNumber] = useState(() => { + const parts = parseSerialNumbers(props.values.inverterSN); + return parts.length > 0 ? parts.length : 1; + }); + const [inverterSerialNumbers, setInverterSerialNumbers] = useState( + () => { + const parts = parseSerialNumbers(props.values.inverterSN); + return parts.length > 0 ? parts : ['']; + } + ); + const [dataloggerSerialNumbers, setDataloggerSerialNumbers] = useState( + () => { + const parts = parseSerialNumbers(props.values.dataloggerSN); + return parts.length > 0 ? parts : ['']; + } + ); + const DeviceTypes = [ { id: 3, name: 'Growatt' }, { id: 4, name: 'Sinexcel' } @@ -141,6 +185,59 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { } } }; + const handleInverterNumberChange = (e) => { + const inputValue = e.target.value; + // Allow empty while user is mid-edit + if (inputValue === '') { + setInverterNumber(''); + return; + } + if (/^\d+$/.test(inputValue)) { + const value = Math.max(1, parseInt(inputValue)); + setInverterNumber(value); + + const newInverterSNs = Array.from({ length: value }, (_, i) => + inverterSerialNumbers[i] || '' + ); + const newDataloggerSNs = Array.from({ length: value }, (_, i) => + dataloggerSerialNumbers[i] || '' + ); + setInverterSerialNumbers(newInverterSNs); + setDataloggerSerialNumbers(newDataloggerSNs); + setFormValues({ + ...formValues, + inverterSN: newInverterSNs.filter((s) => s !== '').join('/'), + dataloggerSN: newDataloggerSNs.filter((s) => s !== '').join('/') + }); + } + }; + + const handleInverterNumberBlur = () => { + if (inverterNumber === '' || inverterNumber < 1) { + setInverterNumber(1); + } + }; + + const handleInverterSerialNumberChange = (index: number, value: string) => { + const updated = [...inverterSerialNumbers]; + updated[index] = value; + setInverterSerialNumbers(updated); + setFormValues({ + ...formValues, + inverterSN: updated.filter((s) => s !== '').join('/') + }); + }; + + const handleDataloggerSerialNumberChange = (index: number, value: string) => { + const updated = [...dataloggerSerialNumbers]; + updated[index] = value; + setDataloggerSerialNumbers(updated); + setFormValues({ + ...formValues, + dataloggerSN: updated.filter((s) => s !== '').join('/') + }); + }; + const handleSubmit = () => { setLoading(true); setError(false); @@ -495,35 +592,54 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { } - name="inverterSN" - value={formValues.inverterSN} - onChange={handleChange} + name="inverterNumber" + type="text" + value={inverterNumber} + onChange={handleInverterNumberChange} + onBlur={handleInverterNumberBlur} variant="outlined" fullWidth inputProps={{ readOnly: !canEdit }} /> -
- 0 && + inverterSerialNumbers.map((sn, index) => ( +
+ + handleInverterSerialNumberChange(index, e.target.value) + } + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit }} /> - } - name="dataloggerSN" - value={formValues.dataloggerSN} - onChange={handleChange} - variant="outlined" - fullWidth - inputProps={{ readOnly: !canEdit }} - /> -
+
+ ))} + + {typeof inverterNumber === 'number' && inverterNumber > 0 && + dataloggerSerialNumbers.map((sn, index) => ( +
+ + handleDataloggerSerialNumberChange(index, e.target.value) + } + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit }} + /> +
+ ))}
Date: Mon, 23 Mar 2026 13:19:14 +0100 Subject: [PATCH 4/4] restructure sodistore home Information tab --- csharp/App/Backend/DataTypes/Installation.cs | 5 +- csharp/App/Backend/Database/Db.cs | 5 + .../Information/InformationSodistoreHome.tsx | 881 ++++++++++-------- .../Information/installationSetupUtils.ts | 112 +++ .../SodistorehomeInstallationForm.tsx | 41 +- .../src/interfaces/InstallationTypes.tsx | 4 + typescript/frontend-marios2/src/lang/de.json | 19 + typescript/frontend-marios2/src/lang/en.json | 19 + typescript/frontend-marios2/src/lang/fr.json | 19 + typescript/frontend-marios2/src/lang/it.json | 19 + 10 files changed, 752 insertions(+), 372 deletions(-) create mode 100644 typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts diff --git a/csharp/App/Backend/DataTypes/Installation.cs b/csharp/App/Backend/DataTypes/Installation.cs index c1b612192..bdf63571d 100644 --- a/csharp/App/Backend/DataTypes/Installation.cs +++ b/csharp/App/Backend/DataTypes/Installation.cs @@ -49,7 +49,10 @@ public class Installation : TreeNode public int BatteryClusterNumber { get; set; } = 0; public int BatteryNumber { get; set; } = 0; public string BatterySerialNumbers { get; set; } = ""; - + public string PvStringsPerInverter { get; set; } = ""; + public string InstallationModel { get; set; } = ""; + public string ExternalEms { get; set; } = "No"; + [Ignore] public String OrderNumbers { get; set; } public String VrmLink { get; set; } = ""; diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 73a22eb0a..e8a197206 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -130,6 +130,11 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); + // Migrate new columns: set defaults for existing rows where NULL or empty + fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); + fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); + fileConnection.Execute("UPDATE Installation SET PvStringsPerInverter = '' WHERE PvStringsPerInverter IS NULL"); + return fileConnection; //return CopyDbToMemory(fileConnection); } diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index 0b3308884..3cb43e526 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx @@ -1,8 +1,12 @@ import { + Accordion, + AccordionDetails, + AccordionSummary, Alert, Autocomplete, Box, CardContent, + Chip, CircularProgress, Container, FormControl, @@ -16,10 +20,11 @@ import { Typography, useTheme } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; -import React, { useContext, useState, useEffect, useRef } from 'react'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import React, { useContext, useState, useEffect } from 'react'; import { I_S3Credentials } from '../../../interfaces/S3Types'; import { I_Installation } from '../../../interfaces/InstallationTypes'; import { InstallationsContext } from '../../../contexts/InstallationsContextProvider'; @@ -28,6 +33,16 @@ import routes from '../../../Resources/routes.json'; import { useNavigate } from 'react-router-dom'; import { UserType } from '../../../interfaces/UserTypes'; import axiosConfig from '../../../Resources/axiosConfig'; +import { + INSTALLATION_PRESETS, + PresetConfig, + BatterySnTree, + parseBatterySnTree, + buildEmptyTree, + remapTree, + computeFlatValues, + wouldLoseData, +} from './installationSetupUtils'; interface InformationSodistorehomeProps { values: I_Installation; @@ -43,15 +58,13 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { const context = useContext(UserContext); const { currentUser } = context; const theme = useTheme(); + const intl = useIntl(); const [formValues, setFormValues] = useState(props.values); const requiredFields = ['name', 'region', 'location', 'country']; const [openModalDeleteInstallation, setOpenModalDeleteInstallation] = useState(false); + const [pendingPreset, setPendingPreset] = useState(null); const navigate = useNavigate(); - const [batteryNumber, setBatteryNumber] = useState(0); - const [batterySerialNumbers, setBatterySerialNumbers] = useState( - [] - ); // Parse inverter/datalogger serial numbers from various legacy formats: // Slash-separated: "SN001/SN002" @@ -71,51 +84,179 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { } } - // Slash-separated format: "SN001/SN002" + // Slash-separated format: "SN001/SN002" — preserve empty positions if (value.includes('/')) { - return value.split('/').filter((s) => s.trim() !== ''); + return value.split('/'); } // Single value return [value.trim()]; }; - const [inverterNumber, setInverterNumber] = useState(() => { - const parts = parseSerialNumbers(props.values.inverterSN); - return parts.length > 0 ? parts.length : 1; - }); - const [inverterSerialNumbers, setInverterSerialNumbers] = useState( - () => { - const parts = parseSerialNumbers(props.values.inverterSN); - return parts.length > 0 ? parts : ['']; - } - ); - const [dataloggerSerialNumbers, setDataloggerSerialNumbers] = useState( - () => { - const parts = parseSerialNumbers(props.values.dataloggerSN); - return parts.length > 0 ? parts : ['']; - } - ); - const DeviceTypes = [ { id: 3, name: 'Growatt' }, { id: 4, name: 'Sinexcel' } ]; - const batterySnRefs = useRef<(HTMLInputElement | null)[]>([]); + // Preset state — initializes from persisted installationModel, empty for legacy + const [selectedPreset, setSelectedPreset] = useState( + props.values.installationModel || '' + ); + const presetConfig: PresetConfig | null = INSTALLATION_PRESETS[selectedPreset] || null; - // Initialize battery data from props - useEffect(() => { - if (props.values.batteryNumber) { - setBatteryNumber(props.values.batteryNumber); + const [batterySnTree, setBatterySnTree] = useState(() => { + if (presetConfig) { + return parseBatterySnTree(props.values.batterySerialNumbers || '', presetConfig); } - if (props.values.batterySerialNumbers) { - const serialNumbers = props.values.batterySerialNumbers - .split(',') - .filter((sn) => sn.trim() !== ''); - setBatterySerialNumbers(serialNumbers); + return []; + }); + + const [inverterSerialNumbers, setInverterSerialNumbers] = useState(() => + parseSerialNumbers(props.values.inverterSN) + ); + const [dataloggerSerialNumbers, setDataloggerSerialNumbers] = useState(() => + parseSerialNumbers(props.values.dataloggerSN) + ); + + // PV strings per inverter — persisted as comma-separated, default "1" per inverter + const [pvStringsPerInverter, setPvStringsPerInverter] = useState(() => { + const stored = props.values.pvStringsPerInverter; + if (stored && stored.trim() !== '') { + return stored.split(',').map((s) => s.trim()); } - }, []); + const invCount = presetConfig?.length + || parseSerialNumbers(props.values.inverterSN).length + || 1; + return Array.from({ length: invCount }, () => '1'); + }); + + const handlePresetChange = (newPreset: string) => { + const newConfig = INSTALLATION_PRESETS[newPreset]; + if (!newConfig) return; + + // Check for data loss — either from existing tree or legacy flat data + const treeToCheck = batterySnTree.length > 0 + ? batterySnTree + : props.values.batterySerialNumbers + ? parseBatterySnTree(props.values.batterySerialNumbers, newConfig) + : []; + if (treeToCheck.length > 0 && wouldLoseData(treeToCheck, newConfig)) { + setPendingPreset(newPreset); + return; + } + + applyPreset(newPreset); + }; + + const handlePresetChangeConfirm = () => { + if (pendingPreset) { + applyPreset(pendingPreset); + setPendingPreset(null); + } + }; + + const handlePresetChangeCancel = () => { + setPendingPreset(null); + }; + + const applyPreset = (newPreset: string) => { + const newConfig = INSTALLATION_PRESETS[newPreset]; + if (!newConfig) return; + + setSelectedPreset(newPreset); + + let newTree: BatterySnTree; + if (presetConfig && batterySnTree.length > 0) { + // Switching between presets — remap existing tree + newTree = remapTree(batterySnTree, newConfig); + } else if (props.values.batterySerialNumbers) { + // First preset selection on legacy installation — parse existing flat data + newTree = parseBatterySnTree(props.values.batterySerialNumbers, newConfig); + } else { + newTree = buildEmptyTree(newConfig); + } + setBatterySnTree(newTree); + + const newInvCount = newConfig.length; + const newInvSNs = Array.from({ length: newInvCount }, (_, i) => + inverterSerialNumbers[i] || '' + ); + const newDlSNs = Array.from({ length: newInvCount }, (_, i) => + dataloggerSerialNumbers[i] || '' + ); + const newPvStrings = Array.from({ length: newInvCount }, (_, i) => + pvStringsPerInverter[i] || '1' + ); + setInverterSerialNumbers(newInvSNs); + setDataloggerSerialNumbers(newDlSNs); + setPvStringsPerInverter(newPvStrings); + + const flat = computeFlatValues(newConfig, newTree); + setFormValues({ + ...formValues, + ...flat, + installationModel: newPreset, + inverterSN: newInvSNs.join('/'), + dataloggerSN: newDlSNs.join('/'), + pvStringsPerInverter: newPvStrings.join(','), + }); + }; + + const handleInverterSnChange = (invIdx: number, value: string) => { + const updated = [...inverterSerialNumbers]; + updated[invIdx] = value; + setInverterSerialNumbers(updated); + setFormValues({ + ...formValues, + inverterSN: updated.join('/'), + }); + }; + + const handleDataloggerSnChange = (invIdx: number, value: string) => { + const updated = [...dataloggerSerialNumbers]; + updated[invIdx] = value; + setDataloggerSerialNumbers(updated); + setFormValues({ + ...formValues, + dataloggerSN: updated.join('/'), + }); + }; + + const handlePvStringsChange = (invIdx: number, value: string) => { + if (value !== '' && !/^\d+$/.test(value)) return; + const updated = [...pvStringsPerInverter]; + updated[invIdx] = value; + setPvStringsPerInverter(updated); + setFormValues({ + ...formValues, + pvStringsPerInverter: updated.join(','), + }); + }; + + const handleBatterySnTreeChange = ( + invIdx: number, + clIdx: number, + batIdx: number, + value: string + ) => { + const newTree = batterySnTree.map((inv, i) => + i === invIdx + ? inv.map((cl, j) => + j === clIdx + ? cl.map((bat, k) => (k === batIdx ? value : bat)) + : cl + ) + : inv + ); + setBatterySnTree(newTree); + if (presetConfig) { + const flat = computeFlatValues(presetConfig, newTree); + setFormValues({ + ...formValues, + ...flat, + }); + } + }; const installationContext = useContext(InstallationsContext); const { @@ -137,107 +278,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { }); }; - const handleBatteryNumberChange = (e) => { - const inputValue = e.target.value; - // Only allow numeric input - if (inputValue === '' || /^\d+$/.test(inputValue)) { - const value = inputValue === '' ? 0 : parseInt(inputValue); - setBatteryNumber(value); - - if (value > 0) { - // Resize array: preserve existing serial numbers, add empty for new slots - const newSerialNumbers = Array.from({ length: value }, (_, index) => { - return batterySerialNumbers[index] || ''; - }); - setBatterySerialNumbers(newSerialNumbers); - - setFormValues({ - ...formValues, - batteryNumber: value, - batterySerialNumbers: newSerialNumbers.filter((sn) => sn !== '').join(',') - }); - } else { - // Field is empty (user is mid-edit) — don't clear serial numbers - setFormValues({ - ...formValues, - batteryNumber: 0 - }); - } - } - }; - - const handleBatterySerialNumberChange = (index: number, value: string) => { - const updatedSerialNumbers = [...batterySerialNumbers]; - updatedSerialNumbers[index] = value; - setBatterySerialNumbers(updatedSerialNumbers); - setFormValues({ - ...formValues, - batterySerialNumbers: updatedSerialNumbers.filter((sn) => sn !== '').join(',') - }); - }; - - const handleBatterySnKeyDown = (e: React.KeyboardEvent, index: number) => { - if (e.key === 'Enter') { - e.preventDefault(); - const nextIndex = index + 1; - if (nextIndex < batteryNumber && batterySnRefs.current[nextIndex]) { - batterySnRefs.current[nextIndex].focus(); - } - } - }; - const handleInverterNumberChange = (e) => { - const inputValue = e.target.value; - // Allow empty while user is mid-edit - if (inputValue === '') { - setInverterNumber(''); - return; - } - if (/^\d+$/.test(inputValue)) { - const value = Math.max(1, parseInt(inputValue)); - setInverterNumber(value); - - const newInverterSNs = Array.from({ length: value }, (_, i) => - inverterSerialNumbers[i] || '' - ); - const newDataloggerSNs = Array.from({ length: value }, (_, i) => - dataloggerSerialNumbers[i] || '' - ); - setInverterSerialNumbers(newInverterSNs); - setDataloggerSerialNumbers(newDataloggerSNs); - setFormValues({ - ...formValues, - inverterSN: newInverterSNs.filter((s) => s !== '').join('/'), - dataloggerSN: newDataloggerSNs.filter((s) => s !== '').join('/') - }); - } - }; - - const handleInverterNumberBlur = () => { - if (inverterNumber === '' || inverterNumber < 1) { - setInverterNumber(1); - } - }; - - const handleInverterSerialNumberChange = (index: number, value: string) => { - const updated = [...inverterSerialNumbers]; - updated[index] = value; - setInverterSerialNumbers(updated); - setFormValues({ - ...formValues, - inverterSN: updated.filter((s) => s !== '').join('/') - }); - }; - - const handleDataloggerSerialNumberChange = (index: number, value: string) => { - const updated = [...dataloggerSerialNumbers]; - updated[index] = value; - setDataloggerSerialNumbers(updated); - setFormValues({ - ...formValues, - dataloggerSN: updated.filter((s) => s !== '').join('/') - }); - }; - const handleSubmit = () => { setLoading(true); setError(false); @@ -389,6 +429,75 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { )} + {pendingPreset !== null && ( + + + + + + +
+ + +
+
+
+ )} + + + + +
- } + label={} name="name" value={formValues.name} onChange={handleChange} @@ -425,9 +533,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
- } + label={} name="region" value={formValues.region} onChange={handleChange} @@ -440,12 +546,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
- } + label={} name="location" value={formValues.location} onChange={handleChange} @@ -458,9 +559,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
- } + label={} name="country" value={formValues.country} onChange={handleChange} @@ -472,87 +571,22 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { />
-
- - setFormValues({ - ...formValues, - networkProvider: (val as string) || '' - }) - } - onInputChange={(_e, val) => - setFormValues({ - ...formValues, - networkProvider: val || '' - }) - } - disabled={!canEdit && !isPartner} - loading={loadingProviders} - renderInput={(params) => ( - - } - variant="outlined" - fullWidth - InputProps={{ - ...params.InputProps, - endAdornment: ( - <> - {loadingProviders ? ( - - ) : null} - {params.InputProps.endAdornment} - - ) - }} - /> - )} - /> -
- - {canEdit && ( + {formValues.installationModel && (
- } - name="vpnIp" - value={formValues.vpnIp} - onChange={handleChange} + label={} + value={formValues.installationModel} variant="outlined" fullWidth + inputProps={{ readOnly: true }} />
)}
- - - + + + { + const val = e.target.value as string; + setFormValues({ ...formValues, externalEms: val }); + }} + inputProps={{ readOnly: !canEdit && !isPartner }} + > + + Solar Manager + Smart Fox + Loxone + + + +
- {typeof inverterNumber === 'number' && inverterNumber > 0 && - inverterSerialNumbers.map((sn, index) => ( -
- - handleInverterSerialNumberChange(index, e.target.value) - } - variant="outlined" - fullWidth - inputProps={{ readOnly: !canEdit }} - /> -
- ))} - - {typeof inverterNumber === 'number' && inverterNumber > 0 && - dataloggerSerialNumbers.map((sn, index) => ( -
- - handleDataloggerSerialNumberChange(index, e.target.value) - } - variant="outlined" - fullWidth - inputProps={{ readOnly: !canEdit }} - /> -
- ))} - -
- - } - name="batteryClusterNumber" - value={formValues.batteryClusterNumber} - onChange={handleChange} - variant="outlined" - fullWidth - inputProps={{ readOnly: !canEdit }} - /> -
- -
- - } - name="batteryNumber" - type="text" - value={batteryNumber === 0 ? '' : batteryNumber} - onChange={handleBatteryNumberChange} - variant="outlined" - fullWidth - placeholder={canEdit ? 'Enter number of batteries' : ''} - inputProps={{ readOnly: !canEdit }} - /> -
- - {batteryNumber > 0 && - batterySerialNumbers.map((serialNumber, index) => ( -
- - handleBatterySerialNumberChange(index, e.target.value) - } - onKeyDown={(e) => handleBatterySnKeyDown(e, index)} - inputRef={(el) => (batterySnRefs.current[index] = el)} - variant="outlined" - fullWidth - placeholder={canEdit ? 'Scan or enter serial number' : ''} - inputProps={{ readOnly: !canEdit }} - /> -
- ))} - + {formValues.externalEms && + !['No', 'Solar Manager', 'Smart Fox', 'Loxone'].includes(formValues.externalEms) && ( +
+ } + name="externalEms" + value={formValues.externalEms === 'Other' ? '' : formValues.externalEms} + onChange={handleChange} + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit && !isPartner }} + /> +
)}
- } + label={} name="information" value={formValues.information} onChange={handleChange} @@ -716,35 +690,202 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { />
- {canEdit && ( + {(canEdit || isPartner) && ( <> + + + +
- + + + Installation Model + + +
+
+ } + name="serialNumber" + value={formValues.serialNumber} + onChange={handleChange} + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit }} + /> +
+ + {presetConfig && presetConfig.map((clusters, invIdx) => { + const invSn = inverterSerialNumbers[invIdx] || ''; + const totalBat = clusters.reduce((a, b) => a + b, 0); + const filledBat = (batterySnTree[invIdx] || []).flat().filter((s) => s !== '').length; + const filledClusters = (batterySnTree[invIdx] || []).filter( + (cl) => cl.some((s) => s !== '') + ).length; + + return ( + + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + + handleInverterSnChange(invIdx, e.target.value)} + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit }} + /> + handleDataloggerSnChange(invIdx, e.target.value)} + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit }} + /> + handlePvStringsChange(invIdx, e.target.value)} + variant="outlined" + fullWidth + inputProps={{ readOnly: !canEdit }} + /> + + {clusters.map((batteryCount, clIdx) => { + const filledInCluster = (batterySnTree[invIdx]?.[clIdx] || []).filter((s) => s !== '').length; + return ( + + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + + {Array.from({ length: batteryCount }, (_, batIdx) => ( + + handleBatterySnTreeChange(invIdx, clIdx, batIdx, e.target.value) + } + variant="outlined" + fullWidth + placeholder={canEdit ? 'Scan or enter serial number' : ''} + inputProps={{ readOnly: !canEdit }} + /> + ))} + + + ); })} + + + ); + })} + + )} + + {canEdit && ( + <> + + + + +
+ } + name="vpnIp" + value={formValues.vpnIp} + onChange={handleChange} + variant="outlined" + fullWidth + /> +
+
+ +
-
= { + 'sodistore home 9': [[1, 1]], + 'sodistore home 18': [[2, 2]], + 'sodistore home 27': [[2, 2], [1, 1]], + 'sodistore home 36': [[2, 2], [2, 2]], +}; + +export const buildEmptyTree = (preset: PresetConfig): BatterySnTree => { + return preset.map((inv) => + inv.map((batteryCount) => Array.from({ length: batteryCount }, () => '')) + ); +}; + +export const parseBatterySnTree = ( + raw: string, + preset: PresetConfig +): BatterySnTree => { + if (!raw || raw.trim() === '') { + return buildEmptyTree(preset); + } + + const isStructured = raw.includes('/') || raw.includes('|'); + + if (isStructured) { + const inverters = raw.split('/'); + return preset.map((invPreset, invIdx) => { + const clusterStr = inverters[invIdx] || ''; + const clusters = clusterStr ? clusterStr.split('|') : []; + return invPreset.map((batteryCount, clIdx) => { + const batteries = clusters[clIdx] + ? clusters[clIdx].split(',').map((s) => s.trim()) + : []; + return Array.from({ length: batteryCount }, (_, i) => batteries[i] || ''); + }); + }); + } + + // Legacy flat format: distribute by preset layout + const allSns = raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + let idx = 0; + return preset.map((inv) => + inv.map((batteryCount) => + Array.from({ length: batteryCount }, () => allSns[idx++] || '') + ) + ); +}; + +export const serializeBatterySnTree = (tree: BatterySnTree): string => { + return tree + .map((inv) => inv.map((cluster) => cluster.join(',')).join('|')) + .join('/'); +}; + +export const remapTree = ( + oldTree: BatterySnTree, + newPreset: PresetConfig +): BatterySnTree => { + return newPreset.map((inv, invIdx) => + inv.map((batteryCount, clIdx) => + Array.from( + { length: batteryCount }, + (_, batIdx) => oldTree[invIdx]?.[clIdx]?.[batIdx] || '' + ) + ) + ); +}; + +export const computeFlatValues = ( + preset: PresetConfig, + tree: BatterySnTree +) => { + const totalBatteries = preset.flat().reduce((a, b) => a + b, 0); + const totalClusters = preset.reduce((sum, inv) => sum + inv.length, 0); + return { + batteryNumber: totalBatteries, + batteryClusterNumber: totalClusters, + batterySerialNumbers: serializeBatterySnTree(tree), + }; +}; + +export const wouldLoseData = ( + oldTree: BatterySnTree, + newPreset: PresetConfig +): boolean => { + for (let invIdx = 0; invIdx < oldTree.length; invIdx++) { + for (let clIdx = 0; clIdx < (oldTree[invIdx] || []).length; clIdx++) { + for ( + let batIdx = 0; + batIdx < (oldTree[invIdx][clIdx] || []).length; + batIdx++ + ) { + const sn = oldTree[invIdx][clIdx][batIdx]; + if (sn && sn.trim() !== '') { + if (invIdx >= newPreset.length) return true; + if (clIdx >= (newPreset[invIdx] || []).length) return true; + const newBatCount = newPreset[invIdx]?.[clIdx] ?? 0; + if (batIdx >= newBatCount) return true; + } + } + } + } + return false; +}; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistorehomeInstallationForm.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistorehomeInstallationForm.tsx index 89bffedd4..764189e54 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistorehomeInstallationForm.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistorehomeInstallationForm.tsx @@ -17,6 +17,7 @@ import { Close as CloseIcon } from '@mui/icons-material'; import { I_Installation } from 'src/interfaces/InstallationTypes'; import { InstallationsContext } from 'src/contexts/InstallationsContextProvider'; import { FormattedMessage } from 'react-intl'; +import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils'; interface SodistorehomeInstallationFormPros { cancel: () => void; @@ -33,8 +34,10 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) location: '', country: '', vpnIp: '', + installationModel: '', + externalEms: 'No', }); - const requiredFields = ['name', 'location', 'country', 'vpnIp']; + const requiredFields = ['name', 'location', 'country', 'vpnIp', 'installationModel']; const DeviceTypes = [ { id: 3, name: 'Growatt' }, @@ -171,6 +174,42 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) />
+
+ + + + + + +
+