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/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/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,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"; + /// /// 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/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index b7b8b6cfa..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,35 +58,205 @@ 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" + // 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" — preserve empty positions + if (value.includes('/')) { + return value.split('/'); + } + + // Single value + return [value.trim()]; + }; 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 { @@ -93,54 +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 handleSubmit = () => { setLoading(true); setError(false); @@ -292,6 +429,75 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { )} + {pendingPreset !== null && ( + + + + + + +
+ + +
+
+
+ )} + + + + +
- } + label={} name="name" value={formValues.name} onChange={handleChange} @@ -328,9 +533,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
- } + label={} name="region" value={formValues.region} onChange={handleChange} @@ -343,12 +546,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
- } + label={} name="location" value={formValues.location} onChange={handleChange} @@ -361,9 +559,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
- } + label={} name="country" value={formValues.country} onChange={handleChange} @@ -375,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 + + + +
-
- - } - name="dataloggerSN" - value={formValues.dataloggerSN} - onChange={handleChange} - 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} @@ -600,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/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/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 1a0351107..2eee428e3 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -104,17 +104,19 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { inverterNumber: inverterNum, batteriesCountPerInverter: batteriesPerInverter, batteriesCount: props.values.Config.BatteriesCount, - clusterNumber: props.values.Config.ClusterNumber || 1, + 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", }; }; @@ -212,9 +214,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(); 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) />
+
+ + + + + + +
+
(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 ( diff --git a/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx b/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx index 0ac72c2fa..f5f1f40b5 100644 --- a/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx @@ -18,6 +18,10 @@ export interface I_Installation extends I_S3Credentials { batteryClusterNumber: number; batteryNumber: number; batterySerialNumbers: string; + pvStringsPerInverter: string; + installationModel: string; + externalEms: string; + parentId: number; s3WriteKey: string; s3WriteSecret: string; diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index fe64612e4..fd35dd1ef 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -73,6 +73,24 @@ "live": "Live Daten", "deleteInstallation": "Installation löschen", "confirmDeleteInstallation": "Möchten Sie diese Installation löschen?", + "installationModel": "Installationsmodell", + "externalEms": "Externes EMS", + "externalEmsOther": "Externes EMS (angeben)", + "emsNo": "Nein", + "emsOther": "Andere", + "generalInfo": "Allgemeine Informationen", + "installationSetup": "Installationseinrichtung", + "selectModel": "Modell auswählen...", + "inverterN": "Wechselrichter {n}", + "clusterN": "Cluster {n}", + "clustersBatteriesSummary": "{filledClusters}/{totalClusters} Cluster, {filledBat}/{totalBat} Batterien", + "batteriesSummary": "{filled}/{total} Batterien", + "inverterNSerialNumber": "Wechselrichter {n} Seriennummer", + "dataloggerNSerialNumber": "Datenlogger {n} Seriennummer", + "pvStringsOnInverterN": "Anzahl PV-Strings an Wechselrichter {n}", + "batteryNSerialNumber": "Batterie {n} Seriennummer", + "adminSection": "Admin", + "confirmPresetSwitch": "Der Wechsel zu einer kleineren Konfiguration entfernt einige Batterie-Seriennummern. Fortfahren?", "bucketLabel": "Bucket", "deleteInstallationWarning": "Bitte notieren Sie den Bucket-Namen oben. Das Löschen der S3-Daten kann mehrere Minuten dauern. Überprüfen Sie nach dem Löschen in Exoscale, ob der Bucket entfernt wurde. Falls nicht, leeren und löschen Sie den Bucket manuell.", "errorOccured": "Ein Fehler ist aufgetreten", @@ -85,6 +103,7 @@ "noUsersWithDirectAccessToThis": "Keine Benutzer mit direktem Zugriff", "selectUsers": "Benutzer auswählen", "cancel": "Abbrechen", + "continue": "Fortfahren", "addNewFolder": "Neuen Ordner hinzufügen", "addNewInstallation": "Neue Installation hinzufügen", "deleteFolder": "Ordner löschen", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 7e065d17d..95703d2e2 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -55,6 +55,24 @@ "live": "Live View", "deleteInstallation": "Delete Installation", "confirmDeleteInstallation": "Do you want to delete this installation?", + "installationModel": "Installation Model", + "externalEms": "External EMS", + "externalEmsOther": "External EMS (specify)", + "emsNo": "No", + "emsOther": "Other", + "generalInfo": "General Info", + "installationSetup": "Installation Setup", + "selectModel": "Select model...", + "inverterN": "Inverter {n}", + "clusterN": "Cluster {n}", + "clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries", + "batteriesSummary": "{filled}/{total} batteries", + "inverterNSerialNumber": "Inverter {n} Serial Number", + "dataloggerNSerialNumber": "Datalogger {n} Serial Number", + "pvStringsOnInverterN": "Number of PV Strings on Inverter {n}", + "batteryNSerialNumber": "Battery {n} Serial Number", + "adminSection": "Admin", + "confirmPresetSwitch": "Switching to a smaller configuration will remove some battery serial number entries. Continue?", "bucketLabel": "Bucket", "deleteInstallationWarning": "Please note the bucket name above. Purging S3 data may take several minutes. After deletion, verify in Exoscale that the bucket has been removed. If not, purge and delete the bucket manually.", "errorOccured": "An error has occurred", @@ -67,6 +85,7 @@ "noUsersWithDirectAccessToThis": "No users with direct access to this ", "selectUsers": "Select Users", "cancel": "Cancel", + "continue": "Continue", "addNewFolder": "Add new Folder", "addNewInstallation": "Add new Installation", "deleteFolder": "Delete Folder", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 7433cd7ed..3cabcacfd 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -67,6 +67,24 @@ "live": "Diffusion en direct", "deleteInstallation": "Supprimer l'installation", "confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?", + "installationModel": "Modèle d'installation", + "externalEms": "EMS externe", + "externalEmsOther": "EMS externe (préciser)", + "emsNo": "Non", + "emsOther": "Autre", + "generalInfo": "Informations générales", + "installationSetup": "Configuration de l'installation", + "selectModel": "Sélectionner le modèle...", + "inverterN": "Onduleur {n}", + "clusterN": "Cluster {n}", + "clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries", + "batteriesSummary": "{filled}/{total} batteries", + "inverterNSerialNumber": "Numéro de série onduleur {n}", + "dataloggerNSerialNumber": "Numéro de série datalogger {n}", + "pvStringsOnInverterN": "Nombre de chaînes PV sur onduleur {n}", + "batteryNSerialNumber": "Numéro de série batterie {n}", + "adminSection": "Admin", + "confirmPresetSwitch": "Le passage à une configuration plus petite supprimera certains numéros de série de batteries. Continuer ?", "bucketLabel": "Bucket", "deleteInstallationWarning": "Veuillez noter le nom du bucket ci-dessus. La purge des données S3 peut prendre plusieurs minutes. Après la suppression, vérifiez dans Exoscale que le bucket a bien été supprimé. Sinon, purgez et supprimez le bucket manuellement.", "errorOccured": "Une erreur s'est produite", @@ -79,6 +97,7 @@ "noUsersWithDirectAccessToThis": "Aucun utilisateur ayant un accès direct", "selectUsers": "Sélectionnez les utilisateurs", "cancel": "Annuler", + "continue": "Continuer", "addNewFolder": "Ajouter un nouveau dossier", "addNewInstallation": "Ajouter une nouvelle installation", "deleteFolder": "Supprimer le dossier", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 5606a3f44..74b396414 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -55,6 +55,24 @@ "live": "Vista in diretta", "deleteInstallation": "Elimina installazione", "confirmDeleteInstallation": "Vuoi eliminare questa installazione?", + "installationModel": "Modello di installazione", + "externalEms": "EMS esterno", + "externalEmsOther": "EMS esterno (specificare)", + "emsNo": "No", + "emsOther": "Altro", + "generalInfo": "Informazioni generali", + "installationSetup": "Configurazione installazione", + "selectModel": "Seleziona modello...", + "inverterN": "Inverter {n}", + "clusterN": "Cluster {n}", + "clustersBatteriesSummary": "{filledClusters}/{totalClusters} cluster, {filledBat}/{totalBat} batterie", + "batteriesSummary": "{filled}/{total} batterie", + "inverterNSerialNumber": "Numero di serie inverter {n}", + "dataloggerNSerialNumber": "Numero di serie datalogger {n}", + "pvStringsOnInverterN": "Numero di stringhe PV sull'inverter {n}", + "batteryNSerialNumber": "Numero di serie batteria {n}", + "adminSection": "Admin", + "confirmPresetSwitch": "Il passaggio a una configurazione più piccola rimuoverà alcuni numeri di serie delle batterie. Continuare?", "bucketLabel": "Bucket", "deleteInstallationWarning": "Prendi nota del nome del bucket qui sopra. L'eliminazione dei dati S3 potrebbe richiedere diversi minuti. Dopo l'eliminazione, verifica in Exoscale che il bucket sia stato rimosso. In caso contrario, svuota ed elimina il bucket manualmente.", "errorOccured": "Si è verificato un errore", @@ -67,6 +85,7 @@ "noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo", "selectUsers": "Seleziona utenti", "cancel": "Annulla", + "continue": "Continua", "addNewFolder": "Aggiungi nuova cartella", "addNewInstallation": "Aggiungi nuova installazione", "deleteFolder": "Elimina cartella",