generate monthly and yearly report based on xlsx files
This commit is contained in:
parent
7476c939c3
commit
78b9c2dc72
|
|
@ -919,6 +919,10 @@ public class Controller : ControllerBase
|
||||||
{
|
{
|
||||||
var lang = language ?? user.Language ?? "en";
|
var lang = language ?? user.Language ?? "en";
|
||||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
|
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
|
||||||
|
|
||||||
|
// Persist weekly summary for future monthly aggregation (idempotent)
|
||||||
|
ReportAggregationService.SaveWeeklySummary(installationId, report);
|
||||||
|
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -956,6 +960,199 @@ public class Controller : ControllerBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Monthly & Yearly Reports ─────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetPendingMonthlyAggregations))]
|
||||||
|
public ActionResult<List<PendingMonth>> GetPendingMonthlyAggregations(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
return Ok(ReportAggregationService.GetPendingMonthlyAggregations(installationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetPendingYearlyAggregations))]
|
||||||
|
public ActionResult<List<PendingYear>> GetPendingYearlyAggregations(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
return Ok(ReportAggregationService.GetPendingYearlyAggregations(installationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetMonthlyReports))]
|
||||||
|
public ActionResult<List<MonthlyReportSummary>> GetMonthlyReports(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
return Ok(Db.GetMonthlyReports(installationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetYearlyReports))]
|
||||||
|
public ActionResult<List<YearlyReportSummary>> GetYearlyReports(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
return Ok(Db.GetYearlyReports(installationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually trigger monthly aggregation for an installation.
|
||||||
|
/// Aggregates weekly reports for the specified year/month into a monthly report.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost(nameof(TriggerMonthlyAggregation))]
|
||||||
|
public async Task<ActionResult> TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, Token authToken)
|
||||||
|
{
|
||||||
|
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 (month < 1 || month > 12)
|
||||||
|
return BadRequest("Month must be between 1 and 12.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lang = user.Language ?? "en";
|
||||||
|
var weekCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang);
|
||||||
|
if (weekCount == 0)
|
||||||
|
return NotFound($"No weekly reports found for {year}-{month:D2}.");
|
||||||
|
|
||||||
|
return Ok(new { message = $"Monthly report created from {weekCount} weekly reports for {year}-{month:D2}." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[TriggerMonthlyAggregation] Error: {ex.Message}");
|
||||||
|
return BadRequest($"Failed to aggregate: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually trigger yearly aggregation for an installation.
|
||||||
|
/// Aggregates monthly reports for the specified year into a yearly report.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost(nameof(TriggerYearlyAggregation))]
|
||||||
|
public async Task<ActionResult> TriggerYearlyAggregation(Int64 installationId, Int32 year, Token authToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lang = user.Language ?? "en";
|
||||||
|
var monthCount = await ReportAggregationService.TriggerYearlyAggregationAsync(installationId, year, lang);
|
||||||
|
if (monthCount == 0)
|
||||||
|
return NotFound($"No monthly reports found for {year}.");
|
||||||
|
|
||||||
|
return Ok(new { message = $"Yearly report created from {monthCount} monthly reports for {year}." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[TriggerYearlyAggregation] Error: {ex.Message}");
|
||||||
|
return BadRequest($"Failed to aggregate: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost(nameof(SendMonthlyReportEmail))]
|
||||||
|
public async Task<ActionResult> SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, String emailAddress, Token authToken)
|
||||||
|
{
|
||||||
|
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 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}.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lang = user.Language ?? "en";
|
||||||
|
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
||||||
|
return Ok(new { message = $"Monthly report sent to {emailAddress}" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SendMonthlyReportEmail] Error: {ex.Message}");
|
||||||
|
return BadRequest($"Failed to send report: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost(nameof(SendYearlyReportEmail))]
|
||||||
|
public async Task<ActionResult> SendYearlyReportEmail(Int64 installationId, Int32 year, String emailAddress, Token authToken)
|
||||||
|
{
|
||||||
|
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 report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year);
|
||||||
|
if (report == null)
|
||||||
|
return BadRequest($"No yearly report found for {year}.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lang = user.Language ?? "en";
|
||||||
|
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
||||||
|
return Ok(new { message = $"Yearly report sent to {emailAddress}" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SendYearlyReportEmail] Error: {ex.Message}");
|
||||||
|
return BadRequest($"Failed to send report: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetWeeklyReportSummaries))]
|
||||||
|
public ActionResult<List<WeeklyReportSummary>> GetWeeklyReportSummaries(Int64 installationId, Int32 year, Int32 month, Token authToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
return Ok(Db.GetWeeklyReportsForMonth(installationId, year, month));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut(nameof(UpdateFolder))]
|
[HttpPut(nameof(UpdateFolder))]
|
||||||
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
||||||
{
|
{
|
||||||
|
|
@ -963,7 +1160,7 @@ public class Controller : ControllerBase
|
||||||
|
|
||||||
if (!session.Update(folder))
|
if (!session.Update(folder))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
return folder.HideParentIfUserHasNoAccessToParent(session!.User);
|
return folder.HideParentIfUserHasNoAccessToParent(session!.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stored summary for a weekly report period.
|
||||||
|
/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation.
|
||||||
|
/// </summary>
|
||||||
|
public class WeeklyReportSummary
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement]
|
||||||
|
public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed]
|
||||||
|
public Int64 InstallationId { get; set; }
|
||||||
|
|
||||||
|
// Period boundaries (ISO date strings: "2026-02-10")
|
||||||
|
public String PeriodStart { get; set; } = "";
|
||||||
|
public String PeriodEnd { get; set; } = "";
|
||||||
|
|
||||||
|
// Energy totals (kWh)
|
||||||
|
public Double TotalPvProduction { get; set; }
|
||||||
|
public Double TotalConsumption { get; set; }
|
||||||
|
public Double TotalGridImport { get; set; }
|
||||||
|
public Double TotalGridExport { get; set; }
|
||||||
|
public Double TotalBatteryCharged { get; set; }
|
||||||
|
public Double TotalBatteryDischarged { get; set; }
|
||||||
|
|
||||||
|
// Derived metrics
|
||||||
|
public Double TotalEnergySaved { get; set; }
|
||||||
|
public Double TotalSavingsCHF { get; set; }
|
||||||
|
public Double SelfSufficiencyPercent { get; set; }
|
||||||
|
public Double SelfConsumptionPercent { get; set; }
|
||||||
|
public Double BatteryEfficiencyPercent { get; set; }
|
||||||
|
public Double GridDependencyPercent { get; set; }
|
||||||
|
|
||||||
|
// Behavioral highlights
|
||||||
|
public Int32 PeakLoadHour { get; set; }
|
||||||
|
public Int32 PeakSolarHour { get; set; }
|
||||||
|
public Double WeekdayAvgDailyLoad { get; set; }
|
||||||
|
public Double WeekendAvgDailyLoad { get; set; }
|
||||||
|
|
||||||
|
// AI insight for this week
|
||||||
|
public String AiInsight { get; set; } = "";
|
||||||
|
|
||||||
|
public String CreatedAt { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregated monthly report. Created from weekly summaries at month-end.
|
||||||
|
/// Consumed and deleted by yearly aggregation.
|
||||||
|
/// </summary>
|
||||||
|
public class MonthlyReportSummary
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement]
|
||||||
|
public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed]
|
||||||
|
public Int64 InstallationId { get; set; }
|
||||||
|
|
||||||
|
public Int32 Year { get; set; }
|
||||||
|
public Int32 Month { get; set; }
|
||||||
|
public String PeriodStart { get; set; } = "";
|
||||||
|
public String PeriodEnd { get; set; } = "";
|
||||||
|
|
||||||
|
// Aggregated energy totals
|
||||||
|
public Double TotalPvProduction { get; set; }
|
||||||
|
public Double TotalConsumption { get; set; }
|
||||||
|
public Double TotalGridImport { get; set; }
|
||||||
|
public Double TotalGridExport { get; set; }
|
||||||
|
public Double TotalBatteryCharged { get; set; }
|
||||||
|
public Double TotalBatteryDischarged { get; set; }
|
||||||
|
|
||||||
|
// Re-derived from aggregated totals
|
||||||
|
public Double TotalEnergySaved { get; set; }
|
||||||
|
public Double TotalSavingsCHF { get; set; }
|
||||||
|
public Double SelfSufficiencyPercent { get; set; }
|
||||||
|
public Double SelfConsumptionPercent { get; set; }
|
||||||
|
public Double BatteryEfficiencyPercent { get; set; }
|
||||||
|
public Double GridDependencyPercent { get; set; }
|
||||||
|
|
||||||
|
// Averaged behavioral highlights
|
||||||
|
public Int32 AvgPeakLoadHour { get; set; }
|
||||||
|
public Int32 AvgPeakSolarHour { get; set; }
|
||||||
|
public Double AvgWeekdayDailyLoad { get; set; }
|
||||||
|
public Double AvgWeekendDailyLoad { get; set; }
|
||||||
|
|
||||||
|
public Int32 WeekCount { get; set; }
|
||||||
|
public String AiInsight { get; set; } = "";
|
||||||
|
public String CreatedAt { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregated yearly report. Created from monthly summaries at year-end.
|
||||||
|
/// Kept indefinitely.
|
||||||
|
/// </summary>
|
||||||
|
public class YearlyReportSummary
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement]
|
||||||
|
public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed]
|
||||||
|
public Int64 InstallationId { get; set; }
|
||||||
|
|
||||||
|
public Int32 Year { get; set; }
|
||||||
|
public String PeriodStart { get; set; } = "";
|
||||||
|
public String PeriodEnd { get; set; } = "";
|
||||||
|
|
||||||
|
// Aggregated energy totals
|
||||||
|
public Double TotalPvProduction { get; set; }
|
||||||
|
public Double TotalConsumption { get; set; }
|
||||||
|
public Double TotalGridImport { get; set; }
|
||||||
|
public Double TotalGridExport { get; set; }
|
||||||
|
public Double TotalBatteryCharged { get; set; }
|
||||||
|
public Double TotalBatteryDischarged { get; set; }
|
||||||
|
|
||||||
|
// Re-derived from aggregated totals
|
||||||
|
public Double TotalEnergySaved { get; set; }
|
||||||
|
public Double TotalSavingsCHF { get; set; }
|
||||||
|
public Double SelfSufficiencyPercent { get; set; }
|
||||||
|
public Double SelfConsumptionPercent { get; set; }
|
||||||
|
public Double BatteryEfficiencyPercent { get; set; }
|
||||||
|
public Double GridDependencyPercent { get; set; }
|
||||||
|
|
||||||
|
// Averaged behavioral highlights
|
||||||
|
public Int32 AvgPeakLoadHour { get; set; }
|
||||||
|
public Int32 AvgPeakSolarHour { get; set; }
|
||||||
|
public Double AvgWeekdayDailyLoad { get; set; }
|
||||||
|
public Double AvgWeekendDailyLoad { get; set; }
|
||||||
|
|
||||||
|
public Int32 MonthCount { get; set; }
|
||||||
|
public String AiInsight { get; set; } = "";
|
||||||
|
public String CreatedAt { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTOs for pending aggregation queries (not stored in DB) ──
|
||||||
|
|
||||||
|
public class PendingMonth
|
||||||
|
{
|
||||||
|
public Int32 Year { get; set; }
|
||||||
|
public Int32 Month { get; set; }
|
||||||
|
public Int32 WeekCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PendingYear
|
||||||
|
{
|
||||||
|
public Int32 Year { get; set; }
|
||||||
|
public Int32 MonthCount { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,10 @@ public static partial class Db
|
||||||
{
|
{
|
||||||
return Insert(action);
|
return Insert(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Boolean Create(WeeklyReportSummary report) => Insert(report);
|
||||||
|
public static Boolean Create(MonthlyReportSummary report) => Insert(report);
|
||||||
|
public static Boolean Create(YearlyReportSummary report) => Insert(report);
|
||||||
|
|
||||||
public static void HandleAction(UserAction newAction)
|
public static void HandleAction(UserAction newAction)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ public static partial class Db
|
||||||
public static TableQuery<Error> Errors => Connection.Table<Error>();
|
public static TableQuery<Error> Errors => Connection.Table<Error>();
|
||||||
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
|
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
|
||||||
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
|
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
|
||||||
|
public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>();
|
||||||
|
public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>();
|
||||||
|
public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>();
|
||||||
|
|
||||||
|
|
||||||
public static void Init()
|
public static void Init()
|
||||||
{
|
{
|
||||||
|
|
@ -51,6 +54,9 @@ public static partial class Db
|
||||||
Connection.CreateTable<Error>();
|
Connection.CreateTable<Error>();
|
||||||
Connection.CreateTable<Warning>();
|
Connection.CreateTable<Warning>();
|
||||||
Connection.CreateTable<UserAction>();
|
Connection.CreateTable<UserAction>();
|
||||||
|
Connection.CreateTable<WeeklyReportSummary>();
|
||||||
|
Connection.CreateTable<MonthlyReportSummary>();
|
||||||
|
Connection.CreateTable<YearlyReportSummary>();
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||||
|
|
@ -88,6 +94,9 @@ public static partial class Db
|
||||||
fileConnection.CreateTable<Error>();
|
fileConnection.CreateTable<Error>();
|
||||||
fileConnection.CreateTable<Warning>();
|
fileConnection.CreateTable<Warning>();
|
||||||
fileConnection.CreateTable<UserAction>();
|
fileConnection.CreateTable<UserAction>();
|
||||||
|
fileConnection.CreateTable<WeeklyReportSummary>();
|
||||||
|
fileConnection.CreateTable<MonthlyReportSummary>();
|
||||||
|
fileConnection.CreateTable<YearlyReportSummary>();
|
||||||
|
|
||||||
return fileConnection;
|
return fileConnection;
|
||||||
//return CopyDbToMemory(fileConnection);
|
//return CopyDbToMemory(fileConnection);
|
||||||
|
|
|
||||||
|
|
@ -141,4 +141,43 @@ public static partial class Db
|
||||||
{
|
{
|
||||||
OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber);
|
OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void DeleteWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month)
|
||||||
|
{
|
||||||
|
var monthStart = $"{year:D4}-{month:D2}-01";
|
||||||
|
var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01";
|
||||||
|
|
||||||
|
// SQLite-net doesn't support string comparison in Delete lambda,
|
||||||
|
// so fetch matching IDs first, then delete by ID.
|
||||||
|
var ids = WeeklyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.ToList()
|
||||||
|
.Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0
|
||||||
|
&& String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0)
|
||||||
|
.Select(r => r.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
WeeklyReports.Delete(r => r.Id == id);
|
||||||
|
|
||||||
|
if (ids.Count > 0) Backup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void DeleteMonthlyReportsForYear(Int64 installationId, Int32 year)
|
||||||
|
{
|
||||||
|
MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
|
||||||
|
Backup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void DeleteMonthlyReport(Int64 installationId, Int32 year, Int32 month)
|
||||||
|
{
|
||||||
|
var count = MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year && r.Month == month);
|
||||||
|
if (count > 0) Backup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void DeleteYearlyReport(Int64 installationId, Int32 year)
|
||||||
|
{
|
||||||
|
var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
|
||||||
|
if (count > 0) Backup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,4 +56,42 @@ public static partial class Db
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Report Queries ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static List<WeeklyReportSummary> GetWeeklyReports(Int64 installationId)
|
||||||
|
=> WeeklyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.OrderByDescending(r => r.PeriodStart)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static List<WeeklyReportSummary> GetWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month)
|
||||||
|
{
|
||||||
|
var monthStart = $"{year:D4}-{month:D2}-01";
|
||||||
|
var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01";
|
||||||
|
return WeeklyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.ToList()
|
||||||
|
.Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0
|
||||||
|
&& String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
|
||||||
|
=> MonthlyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.OrderByDescending(r => r.Year)
|
||||||
|
.ThenByDescending(r => r.Month)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static List<MonthlyReportSummary> GetMonthlyReportsForYear(Int64 installationId, Int32 year)
|
||||||
|
=> MonthlyReports
|
||||||
|
.Where(r => r.InstallationId == installationId && r.Year == year)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static List<YearlyReportSummary> GetYearlyReports(Int64 installationId)
|
||||||
|
=> YearlyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.OrderByDescending(r => r.Year)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ public static class Program
|
||||||
LoadEnvFile();
|
LoadEnvFile();
|
||||||
DiagnosticService.Initialize();
|
DiagnosticService.Initialize();
|
||||||
AlarmReviewService.StartDailyScheduler();
|
AlarmReviewService.StartDailyScheduler();
|
||||||
|
// ReportAggregationService.StartScheduler(); // TODO: uncomment to enable automatic monthly/yearly aggregation
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
RabbitMqManager.InitializeEnvironment();
|
RabbitMqManager.InitializeEnvironment();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,627 @@
|
||||||
|
using Flurl.Http;
|
||||||
|
using InnovEnergy.App.Backend.Database;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
public static class ReportAggregationService
|
||||||
|
{
|
||||||
|
private static Timer? _monthEndTimer;
|
||||||
|
private static Timer? _yearEndTimer;
|
||||||
|
// private static Timer? _sundayReportTimer;
|
||||||
|
|
||||||
|
private const Double ElectricityPriceCHF = 0.39;
|
||||||
|
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||||
|
|
||||||
|
// ── Scheduler ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static void StartScheduler()
|
||||||
|
{
|
||||||
|
// ScheduleSundayWeeklyReport();
|
||||||
|
ScheduleMonthEndJob();
|
||||||
|
ScheduleYearEndJob();
|
||||||
|
Console.WriteLine("[ReportAggregation] Scheduler started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScheduleMonthEndJob()
|
||||||
|
{
|
||||||
|
// Run daily at 02:00, but only act on the 1st of the month
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var next = now.Date.AddHours(2);
|
||||||
|
if (now >= next) next = next.AddDays(1);
|
||||||
|
|
||||||
|
_monthEndTimer = new Timer(
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (DateTime.Now.Day == 1)
|
||||||
|
RunMonthEndAggregation().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Month-end error: {ex.Message}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null, next - now, TimeSpan.FromDays(1));
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 02:00, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScheduleYearEndJob()
|
||||||
|
{
|
||||||
|
// Run daily at 03:00, but only act on Jan 2nd
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var next = now.Date.AddHours(3);
|
||||||
|
if (now >= next) next = next.AddDays(1);
|
||||||
|
|
||||||
|
_yearEndTimer = new Timer(
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2)
|
||||||
|
RunYearEndAggregation().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Year-end error: {ex.Message}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null, next - now, TimeSpan.FromDays(1));
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 03:00, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sunday Weekly Report Automation ─────────────────────────────
|
||||||
|
// Generates weekly reports (Sunday–Saturday) for all SodiStoreHome
|
||||||
|
// installations every Sunday at 06:00, saves summary to DB, and
|
||||||
|
// emails the report to all users who have access to the installation.
|
||||||
|
//
|
||||||
|
// TODO: uncomment ScheduleSundayWeeklyReport() in StartScheduler() to enable.
|
||||||
|
|
||||||
|
// private static void ScheduleSundayWeeklyReport()
|
||||||
|
// {
|
||||||
|
// // Calculate delay until next Sunday 06:00
|
||||||
|
// var now = DateTime.Now;
|
||||||
|
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
|
||||||
|
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
|
||||||
|
//
|
||||||
|
// _sundayReportTimer = new Timer(
|
||||||
|
// _ =>
|
||||||
|
// {
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
|
||||||
|
// RunSundayWeeklyReports().GetAwaiter().GetResult();
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// null, nextSunday - now, TimeSpan.FromDays(7));
|
||||||
|
//
|
||||||
|
// Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static async Task RunSundayWeeklyReports()
|
||||||
|
// {
|
||||||
|
// Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation...");
|
||||||
|
//
|
||||||
|
// // Find all SodiStoreHome installations
|
||||||
|
// var installations = Db.Installations
|
||||||
|
// .Where(i => i.Product == (int)ProductType.SodioHome)
|
||||||
|
// .ToList();
|
||||||
|
//
|
||||||
|
// foreach (var installation in installations)
|
||||||
|
// {
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// // Generate the weekly report (covers last Sunday–Saturday)
|
||||||
|
// var report = await WeeklyReportService.GenerateReportAsync(
|
||||||
|
// installation.Id, installation.InstallationName, "en");
|
||||||
|
//
|
||||||
|
// // Save summary to DB for future monthly aggregation
|
||||||
|
// SaveWeeklySummary(installation.Id, report);
|
||||||
|
//
|
||||||
|
// // Email the report to all users who have access to this installation
|
||||||
|
// var userIds = Db.InstallationAccess
|
||||||
|
// .Where(a => a.InstallationId == installation.Id)
|
||||||
|
// .Select(a => a.UserId)
|
||||||
|
// .ToList();
|
||||||
|
//
|
||||||
|
// foreach (var userId in userIds)
|
||||||
|
// {
|
||||||
|
// var user = Db.GetUserById(userId);
|
||||||
|
// if (user == null || String.IsNullOrWhiteSpace(user.Email))
|
||||||
|
// continue;
|
||||||
|
//
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// var lang = user.Language ?? "en";
|
||||||
|
// // Regenerate with user's language if different from "en"
|
||||||
|
// var localizedReport = lang == "en"
|
||||||
|
// ? report
|
||||||
|
// : await WeeklyReportService.GenerateReportAsync(
|
||||||
|
// installation.Id, installation.InstallationName, lang);
|
||||||
|
//
|
||||||
|
// await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang);
|
||||||
|
// Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}");
|
||||||
|
// }
|
||||||
|
// catch (Exception emailEx)
|
||||||
|
// {
|
||||||
|
// Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete.");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ── Save Weekly Summary ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Remove any existing weekly records whose date range overlaps with this report.
|
||||||
|
// Two periods overlap when: existingStart < newEnd AND newStart < existingEnd.
|
||||||
|
// This prevents double-counting when the same days appear in different report windows
|
||||||
|
// (e.g., report for days 1-7, then later 2-8 — the old 1-7 record is removed).
|
||||||
|
var overlapping = Db.WeeklyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.ToList()
|
||||||
|
.Where(r => String.Compare(r.PeriodStart, report.PeriodEnd, StringComparison.Ordinal) < 0
|
||||||
|
&& String.Compare(report.PeriodStart, r.PeriodEnd, StringComparison.Ordinal) < 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var old in overlapping)
|
||||||
|
Db.WeeklyReports.Delete(r => r.Id == old.Id);
|
||||||
|
|
||||||
|
var summary = new WeeklyReportSummary
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
PeriodStart = report.PeriodStart,
|
||||||
|
PeriodEnd = report.PeriodEnd,
|
||||||
|
TotalPvProduction = report.CurrentWeek.TotalPvProduction,
|
||||||
|
TotalConsumption = report.CurrentWeek.TotalConsumption,
|
||||||
|
TotalGridImport = report.CurrentWeek.TotalGridImport,
|
||||||
|
TotalGridExport = report.CurrentWeek.TotalGridExport,
|
||||||
|
TotalBatteryCharged = report.CurrentWeek.TotalBatteryCharged,
|
||||||
|
TotalBatteryDischarged = report.CurrentWeek.TotalBatteryDischarged,
|
||||||
|
TotalEnergySaved = report.TotalEnergySaved,
|
||||||
|
TotalSavingsCHF = report.TotalSavingsCHF,
|
||||||
|
SelfSufficiencyPercent = report.SelfSufficiencyPercent,
|
||||||
|
SelfConsumptionPercent = report.SelfConsumptionPercent,
|
||||||
|
BatteryEfficiencyPercent = report.BatteryEfficiencyPercent,
|
||||||
|
GridDependencyPercent = report.GridDependencyPercent,
|
||||||
|
PeakLoadHour = report.Behavior?.PeakLoadHour ?? 0,
|
||||||
|
PeakSolarHour = report.Behavior?.PeakSolarHour ?? 0,
|
||||||
|
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
|
||||||
|
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
|
||||||
|
AiInsight = report.AiInsight,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Db.Create(summary);
|
||||||
|
Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}–{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Failed to save weekly summary for installation {installationId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pending Aggregation Queries ─────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns months that have weekly data but no monthly report yet.
|
||||||
|
/// Each entry: { Year, Month, WeekCount }.
|
||||||
|
/// </summary>
|
||||||
|
public static List<PendingMonth> GetPendingMonthlyAggregations(Int64 installationId)
|
||||||
|
{
|
||||||
|
var weeklies = Db.GetWeeklyReports(installationId);
|
||||||
|
var existingMonths = Db.GetMonthlyReports(installationId)
|
||||||
|
.Select(m => (m.Year, m.Month))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
return weeklies
|
||||||
|
.GroupBy(w =>
|
||||||
|
{
|
||||||
|
var date = DateTime.Parse(w.PeriodStart);
|
||||||
|
return (Year: date.Year, Month: date.Month);
|
||||||
|
})
|
||||||
|
.Where(g => !existingMonths.Contains(g.Key))
|
||||||
|
.Select(g => new PendingMonth { Year = g.Key.Year, Month = g.Key.Month, WeekCount = g.Count() })
|
||||||
|
.OrderByDescending(p => p.Year).ThenByDescending(p => p.Month)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns years that have monthly data but no yearly report yet.
|
||||||
|
/// Each entry: { Year, MonthCount }.
|
||||||
|
/// </summary>
|
||||||
|
public static List<PendingYear> GetPendingYearlyAggregations(Int64 installationId)
|
||||||
|
{
|
||||||
|
var monthlies = Db.GetMonthlyReports(installationId);
|
||||||
|
var existingYears = Db.GetYearlyReports(installationId)
|
||||||
|
.Select(y => y.Year)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
return monthlies
|
||||||
|
.GroupBy(m => m.Year)
|
||||||
|
.Where(g => !existingYears.Contains(g.Key))
|
||||||
|
.Select(g => new PendingYear { Year = g.Key, MonthCount = g.Count() })
|
||||||
|
.OrderByDescending(p => p.Year)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Month-End Aggregation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trigger monthly aggregation for a specific installation and month.
|
||||||
|
/// Re-generates if a monthly report already exists. Weekly records are kept.
|
||||||
|
/// Returns the number of weekly records aggregated (0 = no data).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Int32> TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en")
|
||||||
|
{
|
||||||
|
var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month);
|
||||||
|
if (weeklies.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
await AggregateMonthForInstallation(installationId, year, month, language);
|
||||||
|
return weeklies.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trigger yearly aggregation for a specific installation and year.
|
||||||
|
/// Re-generates if a yearly report already exists. Monthly records are kept.
|
||||||
|
/// Returns the number of monthly records aggregated (0 = no data).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Int32> TriggerYearlyAggregationAsync(Int64 installationId, Int32 year, String language = "en")
|
||||||
|
{
|
||||||
|
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||||
|
if (monthlies.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
await AggregateYearForInstallation(installationId, year, language);
|
||||||
|
return monthlies.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunMonthEndAggregation()
|
||||||
|
{
|
||||||
|
var previousMonth = DateTime.Now.AddMonths(-1);
|
||||||
|
var year = previousMonth.Year;
|
||||||
|
var month = previousMonth.Month;
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}...");
|
||||||
|
|
||||||
|
var installationIds = Db.WeeklyReports
|
||||||
|
.Select(r => r.InstallationId)
|
||||||
|
.ToList()
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var installationId in installationIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AggregateMonthForInstallation(installationId, year, month);
|
||||||
|
|
||||||
|
// Scheduler mode: clean up weekly records after successful aggregation.
|
||||||
|
// The monthly report is now the source of truth for this month.
|
||||||
|
Db.DeleteWeeklyReportsForMonth(installationId, year, month);
|
||||||
|
Console.WriteLine($"[ReportAggregation] Cleaned up weekly records for installation {installationId}, {year}-{month:D2}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Month aggregation failed for installation {installationId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en")
|
||||||
|
{
|
||||||
|
var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month);
|
||||||
|
if (weeklies.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// If monthly report already exists, delete it so we can re-generate with latest weekly data.
|
||||||
|
// This supports partial months and re-aggregation when new weekly data arrives.
|
||||||
|
Db.DeleteMonthlyReport(installationId, year, month);
|
||||||
|
|
||||||
|
// Sum energy totals
|
||||||
|
var totalPv = Math.Round(weeklies.Sum(w => w.TotalPvProduction), 1);
|
||||||
|
var totalConsump = Math.Round(weeklies.Sum(w => w.TotalConsumption), 1);
|
||||||
|
var totalGridIn = Math.Round(weeklies.Sum(w => w.TotalGridImport), 1);
|
||||||
|
var totalGridOut = Math.Round(weeklies.Sum(w => w.TotalGridExport), 1);
|
||||||
|
var totalBattChg = Math.Round(weeklies.Sum(w => w.TotalBatteryCharged), 1);
|
||||||
|
var totalBattDis = Math.Round(weeklies.Sum(w => w.TotalBatteryDischarged), 1);
|
||||||
|
|
||||||
|
// Re-derive ratios from aggregated totals
|
||||||
|
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||||
|
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||||
|
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||||
|
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||||
|
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||||
|
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||||
|
|
||||||
|
// Average behavioral highlights
|
||||||
|
var avgPeakLoad = (Int32)Math.Round(weeklies.Average(w => w.PeakLoadHour));
|
||||||
|
var avgPeakSolar = (Int32)Math.Round(weeklies.Average(w => w.PeakSolarHour));
|
||||||
|
var avgWeekdayLoad = Math.Round(weeklies.Average(w => w.WeekdayAvgDailyLoad), 1);
|
||||||
|
var avgWeekendLoad = Math.Round(weeklies.Average(w => w.WeekendAvgDailyLoad), 1);
|
||||||
|
|
||||||
|
// Get installation name for AI insight
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
var installationName = installation?.InstallationName ?? $"Installation {installationId}";
|
||||||
|
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
|
||||||
|
|
||||||
|
var aiInsight = await GenerateMonthlyAiInsightAsync(
|
||||||
|
installationName, monthName, weeklies.Count,
|
||||||
|
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||||
|
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||||
|
selfSufficiency, batteryEff, language);
|
||||||
|
|
||||||
|
var monthlySummary = new MonthlyReportSummary
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
Year = year,
|
||||||
|
Month = month,
|
||||||
|
PeriodStart = weeklies.Min(w => w.PeriodStart),
|
||||||
|
PeriodEnd = weeklies.Max(w => w.PeriodEnd),
|
||||||
|
TotalPvProduction = totalPv,
|
||||||
|
TotalConsumption = totalConsump,
|
||||||
|
TotalGridImport = totalGridIn,
|
||||||
|
TotalGridExport = totalGridOut,
|
||||||
|
TotalBatteryCharged = totalBattChg,
|
||||||
|
TotalBatteryDischarged = totalBattDis,
|
||||||
|
TotalEnergySaved = energySaved,
|
||||||
|
TotalSavingsCHF = savingsCHF,
|
||||||
|
SelfSufficiencyPercent = selfSufficiency,
|
||||||
|
SelfConsumptionPercent = selfConsumption,
|
||||||
|
BatteryEfficiencyPercent = batteryEff,
|
||||||
|
GridDependencyPercent = gridDependency,
|
||||||
|
AvgPeakLoadHour = avgPeakLoad,
|
||||||
|
AvgPeakSolarHour = avgPeakSolar,
|
||||||
|
AvgWeekdayDailyLoad = avgWeekdayLoad,
|
||||||
|
AvgWeekendDailyLoad = avgWeekendLoad,
|
||||||
|
WeekCount = weeklies.Count,
|
||||||
|
AiInsight = aiInsight,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Db.Create(monthlySummary);
|
||||||
|
// Weekly records are kept — allows re-generation if new weekly data arrives.
|
||||||
|
// Cleanup can be done later by the automated scheduler or manually.
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({weeklies.Count} weeks aggregated).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Year-End Aggregation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private static async Task RunYearEndAggregation()
|
||||||
|
{
|
||||||
|
var previousYear = DateTime.Now.Year - 1;
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
||||||
|
|
||||||
|
var installationIds = Db.MonthlyReports
|
||||||
|
.Where(r => r.Year == previousYear)
|
||||||
|
.Select(r => r.InstallationId)
|
||||||
|
.ToList()
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var installationId in installationIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AggregateYearForInstallation(installationId, previousYear);
|
||||||
|
|
||||||
|
// Scheduler mode: clean up monthly records after successful aggregation.
|
||||||
|
// The yearly report is now the source of truth for this year.
|
||||||
|
Db.DeleteMonthlyReportsForYear(installationId, previousYear);
|
||||||
|
Console.WriteLine($"[ReportAggregation] Cleaned up monthly records for installation {installationId}, {previousYear}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en")
|
||||||
|
{
|
||||||
|
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||||
|
if (monthlies.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// If yearly report already exists, delete it so we can re-generate with latest monthly data.
|
||||||
|
Db.DeleteYearlyReport(installationId, year);
|
||||||
|
|
||||||
|
// Sum energy totals
|
||||||
|
var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1);
|
||||||
|
var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1);
|
||||||
|
var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1);
|
||||||
|
var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1);
|
||||||
|
var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1);
|
||||||
|
var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1);
|
||||||
|
|
||||||
|
// Re-derive ratios
|
||||||
|
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||||
|
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||||
|
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||||
|
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||||
|
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||||
|
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||||
|
|
||||||
|
var avgPeakLoad = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakLoadHour));
|
||||||
|
var avgPeakSolar = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakSolarHour));
|
||||||
|
var avgWeekdayLoad = Math.Round(monthlies.Average(m => m.AvgWeekdayDailyLoad), 1);
|
||||||
|
var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1);
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
var installationName = installation?.InstallationName ?? $"Installation {installationId}";
|
||||||
|
|
||||||
|
var aiInsight = await GenerateYearlyAiInsightAsync(
|
||||||
|
installationName, year, monthlies.Count,
|
||||||
|
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||||
|
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||||
|
selfSufficiency, batteryEff, language);
|
||||||
|
|
||||||
|
var yearlySummary = new YearlyReportSummary
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
Year = year,
|
||||||
|
PeriodStart = monthlies.Min(m => m.PeriodStart),
|
||||||
|
PeriodEnd = monthlies.Max(m => m.PeriodEnd),
|
||||||
|
TotalPvProduction = totalPv,
|
||||||
|
TotalConsumption = totalConsump,
|
||||||
|
TotalGridImport = totalGridIn,
|
||||||
|
TotalGridExport = totalGridOut,
|
||||||
|
TotalBatteryCharged = totalBattChg,
|
||||||
|
TotalBatteryDischarged = totalBattDis,
|
||||||
|
TotalEnergySaved = energySaved,
|
||||||
|
TotalSavingsCHF = savingsCHF,
|
||||||
|
SelfSufficiencyPercent = selfSufficiency,
|
||||||
|
SelfConsumptionPercent = selfConsumption,
|
||||||
|
BatteryEfficiencyPercent = batteryEff,
|
||||||
|
GridDependencyPercent = gridDependency,
|
||||||
|
AvgPeakLoadHour = avgPeakLoad,
|
||||||
|
AvgPeakSolarHour = avgPeakSolar,
|
||||||
|
AvgWeekdayDailyLoad = avgWeekdayLoad,
|
||||||
|
AvgWeekendDailyLoad = avgWeekendLoad,
|
||||||
|
MonthCount = monthlies.Count,
|
||||||
|
AiInsight = aiInsight,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Db.Create(yearlySummary);
|
||||||
|
// Monthly records are kept — allows re-generation if new monthly data arrives.
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI Insight Generation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String GetLanguageName(String code) => code switch
|
||||||
|
{
|
||||||
|
"de" => "German",
|
||||||
|
"fr" => "French",
|
||||||
|
"it" => "Italian",
|
||||||
|
_ => "English"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<String> GenerateMonthlyAiInsightAsync(
|
||||||
|
String installationName, String monthName, Int32 weekCount,
|
||||||
|
Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut,
|
||||||
|
Double totalBattChg, Double totalBattDis,
|
||||||
|
Double energySaved, Double savingsCHF,
|
||||||
|
Double selfSufficiency, Double batteryEff,
|
||||||
|
String language = "en")
|
||||||
|
{
|
||||||
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
|
if (String.IsNullOrWhiteSpace(apiKey))
|
||||||
|
return "AI insight unavailable (API key not configured).";
|
||||||
|
|
||||||
|
var langName = GetLanguageName(language);
|
||||||
|
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||||
|
|
||||||
|
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
|
||||||
|
|
||||||
|
MONTHLY FACTS for {monthName} ({weekCount} weeks):
|
||||||
|
- Total PV production: {totalPv:F1} kWh
|
||||||
|
- Total consumption: {totalConsump:F1} kWh
|
||||||
|
- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh
|
||||||
|
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged
|
||||||
|
- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh)
|
||||||
|
- Self-sufficiency: {selfSufficiency:F1}%
|
||||||
|
- Battery efficiency: {batteryEff:F1}%
|
||||||
|
|
||||||
|
INSTRUCTIONS:
|
||||||
|
1. Monthly savings summary: total energy saved and money saved this month. Use the exact numbers provided.
|
||||||
|
2. Solar & battery performance: comment on PV production and battery utilization.
|
||||||
|
3. Grid dependency: note grid import relative to total consumption.
|
||||||
|
4. Recommendation for next month: one actionable suggestion.
|
||||||
|
|
||||||
|
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||||
|
|
||||||
|
return await CallMistralAsync(apiKey, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<String> GenerateYearlyAiInsightAsync(
|
||||||
|
String installationName, Int32 year, Int32 monthCount,
|
||||||
|
Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut,
|
||||||
|
Double totalBattChg, Double totalBattDis,
|
||||||
|
Double energySaved, Double savingsCHF,
|
||||||
|
Double selfSufficiency, Double batteryEff,
|
||||||
|
String language = "en")
|
||||||
|
{
|
||||||
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
|
if (String.IsNullOrWhiteSpace(apiKey))
|
||||||
|
return "AI insight unavailable (API key not configured).";
|
||||||
|
|
||||||
|
var langName = GetLanguageName(language);
|
||||||
|
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||||
|
|
||||||
|
Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown).
|
||||||
|
|
||||||
|
ANNUAL FACTS for {year} ({monthCount} months of data):
|
||||||
|
- Total PV production: {totalPv:F1} kWh
|
||||||
|
- Total consumption: {totalConsump:F1} kWh
|
||||||
|
- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh
|
||||||
|
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged
|
||||||
|
- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh)
|
||||||
|
- Self-sufficiency: {selfSufficiency:F1}%
|
||||||
|
- Battery efficiency: {batteryEff:F1}%
|
||||||
|
|
||||||
|
INSTRUCTIONS:
|
||||||
|
1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided.
|
||||||
|
2. System performance: comment on PV production and battery health indicators.
|
||||||
|
3. Year-over-year readiness: note any trends or areas of improvement.
|
||||||
|
4. Looking ahead: one strategic recommendation for the coming year.
|
||||||
|
|
||||||
|
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||||
|
|
||||||
|
return await CallMistralAsync(apiKey, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<String> CallMistralAsync(String apiKey, String prompt)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
model = "mistral-small-latest",
|
||||||
|
messages = new[] { new { role = "user", content = prompt } },
|
||||||
|
max_tokens = 400,
|
||||||
|
temperature = 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
var responseText = await MistralUrl
|
||||||
|
.WithHeader("Authorization", $"Bearer {apiKey}")
|
||||||
|
.PostJsonAsync(requestBody)
|
||||||
|
.ReceiveString();
|
||||||
|
|
||||||
|
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
|
||||||
|
var content = (String?)envelope?.choices?[0]?.message?.content;
|
||||||
|
|
||||||
|
if (!String.IsNullOrWhiteSpace(content))
|
||||||
|
return content.Trim();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Mistral error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "AI insight could not be generated at this time.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -443,4 +443,230 @@ public static class ReportEmailService
|
||||||
|
|
||||||
private static string ChangeColor(double pct) =>
|
private static string ChangeColor(double pct) =>
|
||||||
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
|
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
|
||||||
|
|
||||||
|
// ── Monthly / Yearly Report Emails ────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly string[] MonthNamesEn = { "", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" };
|
||||||
|
private static readonly string[] MonthNamesDe = { "", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" };
|
||||||
|
private static readonly string[] MonthNamesFr = { "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre" };
|
||||||
|
private static readonly string[] MonthNamesIt = { "", "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre" };
|
||||||
|
|
||||||
|
public static async Task SendMonthlyReportEmailAsync(
|
||||||
|
MonthlyReportSummary report,
|
||||||
|
string installationName,
|
||||||
|
string recipientEmail,
|
||||||
|
string language = "en")
|
||||||
|
{
|
||||||
|
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 html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName,
|
||||||
|
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);
|
||||||
|
|
||||||
|
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SendYearlyReportEmailAsync(
|
||||||
|
YearlyReportSummary report,
|
||||||
|
string installationName,
|
||||||
|
string recipientEmail,
|
||||||
|
string language = "en")
|
||||||
|
{
|
||||||
|
var s = GetAggregatedStrings(language, "yearly");
|
||||||
|
var subject = $"{s.Title} — {installationName} ({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,
|
||||||
|
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||||
|
$"{report.MonthCount} {s.CountLabel}", s);
|
||||||
|
|
||||||
|
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendHtmlEmailAsync(string subject, string html, string recipientEmail)
|
||||||
|
{
|
||||||
|
var config = await ReadMailerConfig();
|
||||||
|
var from = new MailboxAddress(config.SenderName, config.SenderAddress);
|
||||||
|
var to = new MailboxAddress(recipientEmail, recipientEmail);
|
||||||
|
|
||||||
|
var msg = new MimeMessage
|
||||||
|
{
|
||||||
|
From = { from },
|
||||||
|
To = { to },
|
||||||
|
Subject = subject,
|
||||||
|
Body = new TextPart("html") { Text = html }
|
||||||
|
};
|
||||||
|
|
||||||
|
using var smtp = new SmtpClient();
|
||||||
|
await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls);
|
||||||
|
await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword);
|
||||||
|
await smtp.SendAsync(msg);
|
||||||
|
await smtp.DisconnectAsync(true);
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregated report translation strings ─────────────────────────────
|
||||||
|
|
||||||
|
private record AggregatedEmailStrings(
|
||||||
|
string Title, string Insights, string Summary, string SavingsHeader,
|
||||||
|
string Metric, string Total, string PvProduction, string Consumption,
|
||||||
|
string GridImport, string GridExport, string BatteryInOut,
|
||||||
|
string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved,
|
||||||
|
string AtRate, string SolarCoverage, string FromSolar,
|
||||||
|
string BatteryEff, string OutVsIn, string CountLabel, string Footer
|
||||||
|
);
|
||||||
|
|
||||||
|
private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch
|
||||||
|
{
|
||||||
|
("de", "monthly") => new AggregatedEmailStrings(
|
||||||
|
"Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat",
|
||||||
|
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
||||||
|
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
||||||
|
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||||
|
"Wochen aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
("de", "yearly") => new AggregatedEmailStrings(
|
||||||
|
"Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr",
|
||||||
|
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
||||||
|
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
||||||
|
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||||
|
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
("fr", "monthly") => new AggregatedEmailStrings(
|
||||||
|
"Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois",
|
||||||
|
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
||||||
|
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||||
|
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||||
|
"semaines agrégées", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
("fr", "yearly") => new AggregatedEmailStrings(
|
||||||
|
"Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année",
|
||||||
|
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
||||||
|
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||||
|
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||||
|
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
("it", "monthly") => new AggregatedEmailStrings(
|
||||||
|
"Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese",
|
||||||
|
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
||||||
|
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||||
|
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||||
|
"settimane aggregate", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
("it", "yearly") => new AggregatedEmailStrings(
|
||||||
|
"Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno",
|
||||||
|
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
||||||
|
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||||
|
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||||
|
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
(_, "monthly") => new AggregatedEmailStrings(
|
||||||
|
"Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month",
|
||||||
|
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
||||||
|
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
||||||
|
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
||||||
|
"weeks aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||||
|
_ => new AggregatedEmailStrings(
|
||||||
|
"Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year",
|
||||||
|
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
||||||
|
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
||||||
|
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
||||||
|
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>")
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Aggregated HTML email template ────────────────────────────────────
|
||||||
|
|
||||||
|
private 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,
|
||||||
|
double selfSufficiency, double batteryEfficiency, string aiInsight,
|
||||||
|
string countLabel, AggregatedEmailStrings s)
|
||||||
|
{
|
||||||
|
var insightLines = aiInsight
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", ""))
|
||||||
|
.Where(l => l.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var insightHtml = insightLines.Count > 1
|
||||||
|
? "<ul style=\"margin:0;padding-left:20px\">" +
|
||||||
|
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
|
||||||
|
"</ul>"
|
||||||
|
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(aiInsight)}</p>";
|
||||||
|
|
||||||
|
return $@"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset=""utf-8""></head>
|
||||||
|
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
|
||||||
|
<tr><td align=""center"">
|
||||||
|
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
|
||||||
|
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
|
||||||
|
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{installationName}</div>
|
||||||
|
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{periodStart} — {periodEnd}</div>
|
||||||
|
<div style=""font-size:12px;margin-top:2px;opacity:0.5"">{countLabel}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Insights -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:24px 30px 0"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
|
||||||
|
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
|
||||||
|
{insightHtml}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Summary Table -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:24px 30px"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
|
||||||
|
<tr style=""background:#f8f9fa"">
|
||||||
|
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
|
||||||
|
<th style=""padding:8px 12px;text-align:right"">{s.Total}</th>
|
||||||
|
</tr>
|
||||||
|
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.PvProduction}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{pvProduction:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.Consumption}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{consumption:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridImport}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{gridImport:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridExport}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{gridExport:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{batteryCharged:F1} / {batteryDischarged:F1} kWh</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Key Ratios -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:0 30px 24px"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
||||||
|
<tr>
|
||||||
|
{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")}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||||
|
{s.Footer}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl, FormattedMessage } from 'react-intl';
|
import { useIntl, FormattedMessage } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
Grid,
|
Grid,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography
|
||||||
Alert
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
|
||||||
interface WeeklyReportProps {
|
interface WeeklyReportProps {
|
||||||
|
|
@ -57,40 +65,259 @@ interface WeeklyReportResponse {
|
||||||
aiInsight: string;
|
aiInsight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReportSummary {
|
||||||
|
installationId: number;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
totalPvProduction: number;
|
||||||
|
totalConsumption: number;
|
||||||
|
totalGridImport: number;
|
||||||
|
totalGridExport: number;
|
||||||
|
totalBatteryCharged: number;
|
||||||
|
totalBatteryDischarged: number;
|
||||||
|
totalEnergySaved: number;
|
||||||
|
totalSavingsCHF: number;
|
||||||
|
selfSufficiencyPercent: number;
|
||||||
|
selfConsumptionPercent: number;
|
||||||
|
batteryEfficiencyPercent: number;
|
||||||
|
gridDependencyPercent: number;
|
||||||
|
aiInsight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyReport extends ReportSummary {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
weekCount: number;
|
||||||
|
avgPeakLoadHour: number;
|
||||||
|
avgPeakSolarHour: number;
|
||||||
|
avgWeekdayDailyLoad: number;
|
||||||
|
avgWeekendDailyLoad: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YearlyReport extends ReportSummary {
|
||||||
|
year: number;
|
||||||
|
monthCount: number;
|
||||||
|
avgPeakLoadHour: number;
|
||||||
|
avgPeakSolarHour: number;
|
||||||
|
avgWeekdayDailyLoad: number;
|
||||||
|
avgWeekendDailyLoad: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingMonth {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
weekCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingYear {
|
||||||
|
year: number;
|
||||||
|
monthCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyReportSummaryRecord {
|
||||||
|
id: number;
|
||||||
|
installationId: number;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
totalPvProduction: number;
|
||||||
|
totalConsumption: number;
|
||||||
|
totalGridImport: number;
|
||||||
|
totalGridExport: number;
|
||||||
|
totalBatteryCharged: number;
|
||||||
|
totalBatteryDischarged: number;
|
||||||
|
totalEnergySaved: number;
|
||||||
|
totalSavingsCHF: number;
|
||||||
|
selfSufficiencyPercent: number;
|
||||||
|
selfConsumptionPercent: number;
|
||||||
|
batteryEfficiencyPercent: number;
|
||||||
|
gridDependencyPercent: number;
|
||||||
|
peakLoadHour: number;
|
||||||
|
peakSolarHour: number;
|
||||||
|
weekdayAvgDailyLoad: number;
|
||||||
|
weekendAvgDailyLoad: number;
|
||||||
|
aiInsight: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34)
|
// Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34)
|
||||||
// Any number in any language gets bolded — no unit matching needed
|
|
||||||
const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
|
const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
|
||||||
const isBold = (s: string) => /\d/.test(s);
|
const isBold = (s: string) => /\d/.test(s);
|
||||||
|
|
||||||
// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units
|
|
||||||
function FormattedBullet({ text }: { text: string }) {
|
function FormattedBullet({ text }: { text: string }) {
|
||||||
const colonIdx = text.indexOf(':');
|
const colonIdx = text.indexOf(':');
|
||||||
if (colonIdx > 0) {
|
if (colonIdx > 0) {
|
||||||
const title = text.slice(0, colonIdx);
|
const title = text.slice(0, colonIdx);
|
||||||
const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..."
|
const rest = text.slice(colonIdx + 1);
|
||||||
const restParts = rest.split(BOLD_PATTERN).map((p, i) =>
|
const restParts = rest.split(BOLD_PATTERN).map((p, i) =>
|
||||||
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
|
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
|
||||||
);
|
);
|
||||||
return <><strong>{title}</strong>:{restParts}</>;
|
return <><strong>{title}</strong>:{restParts}</>;
|
||||||
}
|
}
|
||||||
// No colon — just bold figures
|
|
||||||
const parts = text.split(BOLD_PATTERN).map((p, i) =>
|
const parts = text.split(BOLD_PATTERN).map((p, i) =>
|
||||||
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
|
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
|
||||||
);
|
);
|
||||||
return <>{parts}</>;
|
return <>{parts}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
|
||||||
|
// ── Email Bar (shared) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmailBar({ onSend, disabled }: { onSend: (email: string) => Promise<void>; disabled?: boolean }) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sendStatus, setSendStatus] = useState<{ message: string; severity: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!email.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onSend(email.trim());
|
||||||
|
setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' });
|
||||||
|
} catch {
|
||||||
|
setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mb: 3, gap: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setSendStatus(null); }}
|
||||||
|
sx={{ width: 280 }}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={sending ? <CircularProgress size={16} color="inherit" /> : <SendIcon />}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || !email.trim() || disabled}
|
||||||
|
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="sendReport" defaultMessage="Send Report" />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{sendStatus && (
|
||||||
|
<Typography variant="caption" sx={{ color: sendStatus.severity === 'success' ? '#27ae60' : '#e74c3c' }}>
|
||||||
|
{sendStatus.message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ─────────────────────────────────────────────
|
||||||
|
|
||||||
function WeeklyReport({ installationId }: WeeklyReportProps) {
|
function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [monthlyReports, setMonthlyReports] = useState<MonthlyReport[]>([]);
|
||||||
|
const [yearlyReports, setYearlyReports] = useState<YearlyReport[]>([]);
|
||||||
|
const [pendingMonths, setPendingMonths] = useState<PendingMonth[]>([]);
|
||||||
|
const [pendingYears, setPendingYears] = useState<PendingYear[]>([]);
|
||||||
|
const [generating, setGenerating] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchReportData = () => {
|
||||||
|
axiosConfig.get('/GetMonthlyReports', { params: { installationId } })
|
||||||
|
.then(res => setMonthlyReports(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
axiosConfig.get('/GetYearlyReports', { params: { installationId } })
|
||||||
|
.then(res => setYearlyReports(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
axiosConfig.get('/GetPendingMonthlyAggregations', { params: { installationId } })
|
||||||
|
.then(res => setPendingMonths(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
axiosConfig.get('/GetPendingYearlyAggregations', { params: { installationId } })
|
||||||
|
.then(res => setPendingYears(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchReportData(); }, [installationId]);
|
||||||
|
|
||||||
|
const handleGenerateMonthly = async (year: number, month: number) => {
|
||||||
|
setGenerating(`monthly-${year}-${month}`);
|
||||||
|
try {
|
||||||
|
await axiosConfig.post('/TriggerMonthlyAggregation', null, {
|
||||||
|
params: { installationId, year, month }
|
||||||
|
});
|
||||||
|
fetchReportData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Monthly aggregation failed', err);
|
||||||
|
} finally {
|
||||||
|
setGenerating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateYearly = async (year: number) => {
|
||||||
|
setGenerating(`yearly-${year}`);
|
||||||
|
try {
|
||||||
|
await axiosConfig.post('/TriggerYearlyAggregation', null, {
|
||||||
|
params: { installationId, year }
|
||||||
|
});
|
||||||
|
fetchReportData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Yearly aggregation failed', err);
|
||||||
|
} finally {
|
||||||
|
setGenerating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
|
||||||
|
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
|
||||||
|
{ label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const safeTab = Math.min(activeTab, tabs.length - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Tabs
|
||||||
|
value={safeTab}
|
||||||
|
onChange={(_, v) => setActiveTab(v)}
|
||||||
|
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
||||||
|
>
|
||||||
|
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{tabs[safeTab]?.key === 'weekly' && (
|
||||||
|
<WeeklySection installationId={installationId} />
|
||||||
|
)}
|
||||||
|
{tabs[safeTab]?.key === 'monthly' && (
|
||||||
|
<MonthlySection
|
||||||
|
installationId={installationId}
|
||||||
|
reports={monthlyReports}
|
||||||
|
pendingMonths={pendingMonths}
|
||||||
|
generating={generating}
|
||||||
|
onGenerate={handleGenerateMonthly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tabs[safeTab]?.key === 'yearly' && (
|
||||||
|
<YearlySection
|
||||||
|
installationId={installationId}
|
||||||
|
reports={yearlyReports}
|
||||||
|
pendingYears={pendingYears}
|
||||||
|
generating={generating}
|
||||||
|
onGenerate={handleGenerateYearly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weekly Section (existing weekly report content) ────────────
|
||||||
|
|
||||||
|
function WeeklySection({ installationId }: { installationId: number }) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
|
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [sendStatus, setSendStatus] = useState<{
|
|
||||||
message: string;
|
|
||||||
severity: 'success' | 'error';
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchReport();
|
fetchReport();
|
||||||
|
|
@ -115,19 +342,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendEmail = async () => {
|
const handleSendEmail = async (emailAddress: string) => {
|
||||||
if (!email.trim()) return;
|
await axiosConfig.post('/SendWeeklyReportEmail', null, {
|
||||||
setSending(true);
|
params: { installationId, emailAddress }
|
||||||
try {
|
});
|
||||||
await axiosConfig.post('/SendWeeklyReportEmail', null, {
|
|
||||||
params: { installationId, emailAddress: email.trim() }
|
|
||||||
});
|
|
||||||
setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' });
|
|
||||||
} catch (err: any) {
|
|
||||||
setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -162,7 +380,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
const cur = report.currentWeek;
|
const cur = report.currentWeek;
|
||||||
const prev = report.previousWeek;
|
const prev = report.previousWeek;
|
||||||
|
|
||||||
// Backend: currentWeek = last 7 days, previousWeek = everything before
|
|
||||||
const currentWeekDayCount = Math.min(7, report.dailyData.length);
|
const currentWeekDayCount = Math.min(7, report.dailyData.length);
|
||||||
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount);
|
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount);
|
||||||
|
|
||||||
|
|
@ -174,51 +391,23 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
|
return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse AI insight into bullet points
|
|
||||||
const insightBullets = report.aiInsight
|
const insightBullets = report.aiInsight
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
|
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
// Read pre-computed values from backend — no arithmetic in the frontend
|
|
||||||
const totalEnergySavedKwh = report.totalEnergySaved;
|
const totalEnergySavedKwh = report.totalEnergySaved;
|
||||||
const totalSavingsCHF = report.totalSavingsCHF;
|
const totalSavingsCHF = report.totalSavingsCHF;
|
||||||
|
|
||||||
// Find max value for daily bar chart scaling
|
|
||||||
const maxDailyValue = Math.max(
|
const maxDailyValue = Math.max(
|
||||||
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
|
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2 }}>
|
<>
|
||||||
{/* Email bar */}
|
{/* Email bar */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mb: 3, gap: 0.5 }}>
|
<EmailBar onSend={handleSendEmail} />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
placeholder="recipient@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => { setEmail(e.target.value); setSendStatus(null); }}
|
|
||||||
sx={{ width: 280 }}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSendEmail()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={sending ? <CircularProgress size={16} color="inherit" /> : <SendIcon />}
|
|
||||||
onClick={handleSendEmail}
|
|
||||||
disabled={sending || !email.trim()}
|
|
||||||
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="sendReport" defaultMessage="Send Report" />
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{sendStatus && (
|
|
||||||
<Typography variant="caption" sx={{ color: sendStatus.severity === 'success' ? '#27ae60' : '#e74c3c' }}>
|
|
||||||
{sendStatus.message}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Report Header */}
|
{/* Report Header */}
|
||||||
<Paper
|
<Paper
|
||||||
|
|
@ -241,31 +430,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Weekly Insights (was AI Insights) */}
|
{/* Weekly Insights */}
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
|
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<InsightBox text={report.aiInsight} bullets={insightBullets} />
|
||||||
sx={{
|
|
||||||
bgcolor: '#fef9e7',
|
|
||||||
borderLeft: '4px solid #f39c12',
|
|
||||||
p: 2.5,
|
|
||||||
borderRadius: '0 8px 8px 0'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{insightBullets.length > 1 ? (
|
|
||||||
<Box component="ul" sx={{ m: 0, pl: 2.5, '& li': { mb: 1.2, lineHeight: 1.7, fontSize: '14px', color: '#333' } }}>
|
|
||||||
{insightBullets.map((bullet, i) => (
|
|
||||||
<li key={i}><FormattedBullet text={bullet} /></li>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography sx={{ lineHeight: 1.7, fontSize: '14px', color: '#333' }}>
|
|
||||||
<FormattedBullet text={report.aiInsight} />
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Your Savings This Week */}
|
{/* Your Savings This Week */}
|
||||||
|
|
@ -273,41 +443,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
|
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<SavingsCards
|
||||||
<Grid item xs={6} sm={3}>
|
intl={intl}
|
||||||
<SavingsCard
|
energySaved={totalEnergySavedKwh}
|
||||||
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
|
savingsCHF={totalSavingsCHF}
|
||||||
value={`${totalEnergySavedKwh} kWh`}
|
selfSufficiency={report.selfSufficiencyPercent}
|
||||||
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
|
batteryEfficiency={report.batteryEfficiencyPercent}
|
||||||
color="#27ae60"
|
hint={report.daysEquivalent > 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
|
||||||
hint={report.daysEquivalent > 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
|
/>
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6} sm={3}>
|
|
||||||
<SavingsCard
|
|
||||||
label={intl.formatMessage({ id: 'estMoneySaved' })}
|
|
||||||
value={`~${totalSavingsCHF} CHF`}
|
|
||||||
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
|
|
||||||
color="#2980b9"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6} sm={3}>
|
|
||||||
<SavingsCard
|
|
||||||
label={intl.formatMessage({ id: 'solarCoverage' })}
|
|
||||||
value={`${report.selfSufficiencyPercent.toFixed(0)}%`}
|
|
||||||
subtitle={intl.formatMessage({ id: 'fromSolarSub' })}
|
|
||||||
color="#8e44ad"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6} sm={3}>
|
|
||||||
<SavingsCard
|
|
||||||
label={intl.formatMessage({ id: 'batteryEfficiency' })}
|
|
||||||
value={`${report.batteryEfficiencyPercent.toFixed(0)}%`}
|
|
||||||
subtitle={intl.formatMessage({ id: 'batteryEffSub' })}
|
|
||||||
color="#e67e22"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Weekly Summary Table */}
|
{/* Weekly Summary Table */}
|
||||||
|
|
@ -371,13 +514,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Daily Breakdown - CSS bar chart */}
|
{/* Daily Breakdown */}
|
||||||
{report.dailyData.length > 0 && (
|
{report.dailyData.length > 0 && (
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
|
||||||
<FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" />
|
<FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" />
|
||||||
</Typography>
|
</Typography>
|
||||||
{/* Legend */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}>
|
<Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Box sx={{ width: 12, height: 12, bgcolor: '#f39c12', borderRadius: '2px' }} /> <FormattedMessage id="pvProduction" defaultMessage="PV Production" />
|
<Box sx={{ width: 12, height: 12, bgcolor: '#f39c12', borderRadius: '2px' }} /> <FormattedMessage id="pvProduction" defaultMessage="PV Production" />
|
||||||
|
|
@ -389,7 +531,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
|
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Bars */}
|
|
||||||
{report.dailyData.map((d, i) => {
|
{report.dailyData.map((d, i) => {
|
||||||
const dt = new Date(d.date);
|
const dt = new Date(d.date);
|
||||||
const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
|
@ -406,32 +547,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
|
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
|
||||||
<Box
|
<Box sx={{ width: `${(d.pvProduction / maxDailyValue) * 100}%`, bgcolor: '#f39c12', borderRadius: '2px 0 0 2px', minWidth: d.pvProduction > 0 ? '2px' : 0, transition: 'width 0.3s' }} />
|
||||||
sx={{
|
<Box sx={{ width: `${(d.loadConsumption / maxDailyValue) * 100}%`, bgcolor: '#3498db', minWidth: d.loadConsumption > 0 ? '2px' : 0, transition: 'width 0.3s' }} />
|
||||||
width: `${(d.pvProduction / maxDailyValue) * 100}%`,
|
<Box sx={{ width: `${(d.gridImport / maxDailyValue) * 100}%`, bgcolor: '#e74c3c', borderRadius: '0 2px 2px 0', minWidth: d.gridImport > 0 ? '2px' : 0, transition: 'width 0.3s' }} />
|
||||||
bgcolor: '#f39c12',
|
|
||||||
borderRadius: '2px 0 0 2px',
|
|
||||||
minWidth: d.pvProduction > 0 ? '2px' : 0,
|
|
||||||
transition: 'width 0.3s'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${(d.loadConsumption / maxDailyValue) * 100}%`,
|
|
||||||
bgcolor: '#3498db',
|
|
||||||
minWidth: d.loadConsumption > 0 ? '2px' : 0,
|
|
||||||
transition: 'width 0.3s'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${(d.gridImport / maxDailyValue) * 100}%`,
|
|
||||||
bgcolor: '#e74c3c',
|
|
||||||
borderRadius: '0 2px 2px 0',
|
|
||||||
minWidth: d.gridImport > 0 ? '2px' : 0,
|
|
||||||
transition: 'width 0.3s'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -439,10 +557,470 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Weekly History for current month */}
|
||||||
|
<WeeklyHistory installationId={installationId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weekly History (saved weekly reports for current month) ─────
|
||||||
|
|
||||||
|
function WeeklyHistory({ installationId }: { installationId: number }) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [records, setRecords] = useState<WeeklyReportSummaryRecord[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const now = new Date();
|
||||||
|
axiosConfig.get('/GetWeeklyReportSummaries', {
|
||||||
|
params: { installationId, year: now.getFullYear(), month: now.getMonth() + 1 }
|
||||||
|
})
|
||||||
|
.then(res => setRecords(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [installationId]);
|
||||||
|
|
||||||
|
if (records.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
<FormattedMessage id="thisMonthWeeklyReports" defaultMessage="This Month's Weekly Reports" />
|
||||||
|
</Typography>
|
||||||
|
{records.map((rec) => (
|
||||||
|
<Accordion key={rec.id} sx={{ mb: 1, '&:before': { display: 'none' } }} disableGutters>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ bgcolor: '#f8f9fa', borderRadius: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', pr: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
{rec.periodStart} — {rec.periodEnd}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||||
|
PV {rec.totalPvProduction.toFixed(1)} kWh | {intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' })} {rec.totalConsumption.toFixed(1)} kWh
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 1 }}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" fontWeight="bold" color="#27ae60">{rec.totalEnergySaved.toFixed(1)} kWh</Typography>
|
||||||
|
<Typography variant="caption" color="#888"><FormattedMessage id="solarEnergyUsed" defaultMessage="Energy Saved" /></Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" fontWeight="bold" color="#2980b9">~{rec.totalSavingsCHF.toFixed(0)} CHF</Typography>
|
||||||
|
<Typography variant="caption" color="#888"><FormattedMessage id="estMoneySaved" defaultMessage="Est. Money Saved" /></Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" fontWeight="bold" color="#8e44ad">{rec.selfSufficiencyPercent.toFixed(0)}%</Typography>
|
||||||
|
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Self-Sufficiency" /></Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" fontWeight="bold" color="#e67e22">{rec.batteryEfficiencyPercent.toFixed(0)}%</Typography>
|
||||||
|
<Typography variant="caption" color="#888"><FormattedMessage id="batteryEfficiency" defaultMessage="Battery Eff." /></Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px', '& td': { p: 0.8, borderBottom: '1px solid #f0f0f0' } }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{rec.totalGridImport.toFixed(1)} kWh</td>
|
||||||
|
<td style={{ width: 20 }} />
|
||||||
|
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{rec.totalGridExport.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
|
||||||
|
<td style={{ textAlign: 'right' }} colSpan={4}>{rec.totalBatteryCharged.toFixed(1)} / {rec.totalBatteryDischarged.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Box>
|
||||||
|
{rec.aiInsight && rec.aiInsight.length > 10 && (
|
||||||
|
<Box sx={{ mt: 1.5, bgcolor: '#fef9e7', borderLeft: '3px solid #f39c12', p: 1.5, borderRadius: '0 6px 6px 0', fontSize: '12px', color: '#555' }}>
|
||||||
|
{rec.aiInsight.split(/\n+/).filter(l => l.trim()).slice(0, 2).map((line, i) => (
|
||||||
|
<Typography key={i} variant="caption" display="block" sx={{ lineHeight: 1.5 }}>
|
||||||
|
{line.replace(/^[-*]\s*/, '').replace(/^[\d]+[.)]\s*/, '')}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Monthly Section ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MonthlySection({
|
||||||
|
installationId,
|
||||||
|
reports,
|
||||||
|
pendingMonths,
|
||||||
|
generating,
|
||||||
|
onGenerate
|
||||||
|
}: {
|
||||||
|
installationId: number;
|
||||||
|
reports: MonthlyReport[];
|
||||||
|
pendingMonths: PendingMonth[];
|
||||||
|
generating: string | null;
|
||||||
|
onGenerate: (year: number, month: number) => void;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Generate buttons for pending months */}
|
||||||
|
{pendingMonths.length > 0 && (
|
||||||
|
<Paper sx={{ p: 2, mb: 3, bgcolor: '#f0f7ff', border: '1px solid #bbdefb' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, color: '#1565c0', fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="availableForGeneration" defaultMessage="Available for Generation" />
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{pendingMonths.map(p => {
|
||||||
|
const key = `monthly-${p.year}-${p.month}`;
|
||||||
|
const isGenerating = generating === key;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
disabled={generating !== null}
|
||||||
|
onClick={() => onGenerate(p.year, p.month)}
|
||||||
|
startIcon={isGenerating ? <CircularProgress size={14} /> : undefined}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{isGenerating
|
||||||
|
? intl.formatMessage({ id: 'generatingMonthly', defaultMessage: 'Generating...' })
|
||||||
|
: intl.formatMessage(
|
||||||
|
{ id: 'generateMonth', defaultMessage: 'Generate {month} {year} ({count} weeks)' },
|
||||||
|
{ month: MONTH_NAMES[p.month], year: p.year, count: p.weekCount }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing monthly reports */}
|
||||||
|
{reports.length > 0 ? (
|
||||||
|
<AggregatedSection
|
||||||
|
reports={reports}
|
||||||
|
type="monthly"
|
||||||
|
labelFn={(r: MonthlyReport) => `${MONTH_NAMES[r.month]} ${r.year}`}
|
||||||
|
countLabelId="weeksAggregated"
|
||||||
|
countFn={(r: MonthlyReport) => r.weekCount}
|
||||||
|
sendEndpoint="/SendMonthlyReportEmail"
|
||||||
|
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
||||||
|
/>
|
||||||
|
) : pendingMonths.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
<FormattedMessage id="noMonthlyData" defaultMessage="No monthly reports available yet. Weekly reports will appear here for aggregation once generated." />
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Yearly Section ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function YearlySection({
|
||||||
|
installationId,
|
||||||
|
reports,
|
||||||
|
pendingYears,
|
||||||
|
generating,
|
||||||
|
onGenerate
|
||||||
|
}: {
|
||||||
|
installationId: number;
|
||||||
|
reports: YearlyReport[];
|
||||||
|
pendingYears: PendingYear[];
|
||||||
|
generating: string | null;
|
||||||
|
onGenerate: (year: number) => void;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Generate buttons for pending years */}
|
||||||
|
{pendingYears.length > 0 && (
|
||||||
|
<Paper sx={{ p: 2, mb: 3, bgcolor: '#f0f7ff', border: '1px solid #bbdefb' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, color: '#1565c0', fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="availableForGeneration" defaultMessage="Available for Generation" />
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{pendingYears.map(p => {
|
||||||
|
const key = `yearly-${p.year}`;
|
||||||
|
const isGenerating = generating === key;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
disabled={generating !== null}
|
||||||
|
onClick={() => onGenerate(p.year)}
|
||||||
|
startIcon={isGenerating ? <CircularProgress size={14} /> : undefined}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{isGenerating
|
||||||
|
? intl.formatMessage({ id: 'generatingYearly', defaultMessage: 'Generating...' })
|
||||||
|
: intl.formatMessage(
|
||||||
|
{ id: 'generateYear', defaultMessage: 'Generate {year} ({count} months)' },
|
||||||
|
{ year: p.year, count: p.monthCount }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing yearly reports */}
|
||||||
|
{reports.length > 0 ? (
|
||||||
|
<AggregatedSection
|
||||||
|
reports={reports}
|
||||||
|
type="yearly"
|
||||||
|
labelFn={(r: YearlyReport) => `${r.year}`}
|
||||||
|
countLabelId="monthsAggregated"
|
||||||
|
countFn={(r: YearlyReport) => r.monthCount}
|
||||||
|
sendEndpoint="/SendYearlyReportEmail"
|
||||||
|
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
||||||
|
/>
|
||||||
|
) : pendingYears.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
<FormattedMessage id="noYearlyData" defaultMessage="No yearly reports available yet. Monthly reports will appear here for aggregation once generated." />
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregated Section (Monthly / Yearly) ──────────────────────
|
||||||
|
|
||||||
|
function AggregatedSection<T extends ReportSummary>({
|
||||||
|
reports,
|
||||||
|
type,
|
||||||
|
labelFn,
|
||||||
|
countLabelId,
|
||||||
|
countFn,
|
||||||
|
sendEndpoint,
|
||||||
|
sendParamsFn
|
||||||
|
}: {
|
||||||
|
reports: T[];
|
||||||
|
type: 'monthly' | 'yearly';
|
||||||
|
labelFn: (r: T) => string;
|
||||||
|
countLabelId: string;
|
||||||
|
countFn: (r: T) => number;
|
||||||
|
sendEndpoint: string;
|
||||||
|
sendParamsFn: (r: T) => object;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
|
|
||||||
|
if (reports.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert severity="info">
|
||||||
|
<FormattedMessage
|
||||||
|
id={type === 'monthly' ? 'noMonthlyData' : 'noYearlyData'}
|
||||||
|
defaultMessage={type === 'monthly' ? 'No monthly reports available yet.' : 'No yearly reports available yet.'}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = reports[selectedIdx];
|
||||||
|
const insightsId = type === 'monthly' ? 'monthlyInsights' : 'yearlyInsights';
|
||||||
|
const savingsId = type === 'monthly' ? 'monthlySavings' : 'yearlySavings';
|
||||||
|
const summaryId = type === 'monthly' ? 'monthlySummary' : 'yearlySummary';
|
||||||
|
const titleId = type === 'monthly' ? 'monthlyReportTitle' : 'yearlyReportTitle';
|
||||||
|
|
||||||
|
const insightBullets = r.aiInsight
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
const handleSendEmail = async (emailAddress: string) => {
|
||||||
|
await axiosConfig.post(sendEndpoint, null, {
|
||||||
|
params: { ...sendParamsFn(r), emailAddress }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Period selector + Email bar */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 2, mb: 2 }}>
|
||||||
|
{reports.length > 1 && (
|
||||||
|
<Select
|
||||||
|
value={selectedIdx}
|
||||||
|
onChange={(e) => setSelectedIdx(Number(e.target.value))}
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
>
|
||||||
|
{reports.map((rep, i) => (
|
||||||
|
<MenuItem key={i} value={i}>{labelFn(rep)}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Box sx={{ ml: 'auto' }}>
|
||||||
|
<EmailBar onSend={handleSendEmail} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Paper sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
<FormattedMessage id={titleId} defaultMessage={type === 'monthly' ? 'Monthly Performance Report' : 'Annual Performance Report'} />
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||||
|
{r.periodStart} — {r.periodEnd}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.6 }}>
|
||||||
|
<FormattedMessage id={countLabelId} defaultMessage="{count} periods aggregated" values={{ count: countFn(r) }} />
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* AI Insights */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
<FormattedMessage id={insightsId} defaultMessage={type === 'monthly' ? 'Monthly Insights' : 'Annual Insights'} />
|
||||||
|
</Typography>
|
||||||
|
<InsightBox text={r.aiInsight} bullets={insightBullets} />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Savings Cards */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
<FormattedMessage id={savingsId} defaultMessage={type === 'monthly' ? 'Your Savings This Month' : 'Your Savings This Year'} />
|
||||||
|
</Typography>
|
||||||
|
<SavingsCards
|
||||||
|
intl={intl}
|
||||||
|
energySaved={r.totalEnergySaved}
|
||||||
|
savingsCHF={r.totalSavingsCHF}
|
||||||
|
selfSufficiency={r.selfSufficiencyPercent}
|
||||||
|
batteryEfficiency={r.batteryEfficiencyPercent}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Summary Table */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
<FormattedMessage id={summaryId} defaultMessage={type === 'monthly' ? 'Monthly Summary' : 'Annual Summary'} />
|
||||||
|
</Typography>
|
||||||
|
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', '& td, & th': { p: 1.2, borderBottom: '1px solid #eee' } }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8f9fa' }}>
|
||||||
|
<th style={{ textAlign: 'left' }}><FormattedMessage id="metric" defaultMessage="Metric" /></th>
|
||||||
|
<th style={{ textAlign: 'right' }}><FormattedMessage id="total" defaultMessage="Total" /></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalPvProduction.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalConsumption.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalGridImport.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalGridExport.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery Charge / Discharge" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalBatteryCharged.toFixed(1)} / {r.totalBatteryDischarged.toFixed(1)} kWh</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared Components ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function InsightBox({ text, bullets }: { text: string; bullets: string[] }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#fef9e7',
|
||||||
|
borderLeft: '4px solid #f39c12',
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: '0 8px 8px 0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bullets.length > 1 ? (
|
||||||
|
<Box component="ul" sx={{ m: 0, pl: 2.5, '& li': { mb: 1.2, lineHeight: 1.7, fontSize: '14px', color: '#333' } }}>
|
||||||
|
{bullets.map((bullet, i) => (
|
||||||
|
<li key={i}><FormattedBullet text={bullet} /></li>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ lineHeight: 1.7, fontSize: '14px', color: '#333' }}>
|
||||||
|
<FormattedBullet text={text} />
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
|
||||||
|
intl: any;
|
||||||
|
energySaved: number;
|
||||||
|
savingsCHF: number;
|
||||||
|
selfSufficiency: number;
|
||||||
|
batteryEfficiency: number;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
|
||||||
|
value={`${energySaved} kWh`}
|
||||||
|
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
|
||||||
|
color="#27ae60"
|
||||||
|
hint={hint}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label={intl.formatMessage({ id: 'estMoneySaved' })}
|
||||||
|
value={`~${savingsCHF} CHF`}
|
||||||
|
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
|
||||||
|
color="#2980b9"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label={intl.formatMessage({ id: 'solarCoverage' })}
|
||||||
|
value={`${selfSufficiency.toFixed(0)}%`}
|
||||||
|
subtitle={intl.formatMessage({ id: 'fromSolarSub' })}
|
||||||
|
color="#8e44ad"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label={intl.formatMessage({ id: 'batteryEfficiency' })}
|
||||||
|
value={`${batteryEfficiency.toFixed(0)}%`}
|
||||||
|
subtitle={intl.formatMessage({ id: 'batteryEffSub' })}
|
||||||
|
color="#e67e22"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) {
|
function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,28 @@
|
||||||
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
|
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
|
||||||
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
|
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
|
||||||
"noReportData": "Keine Berichtsdaten gefunden.",
|
"noReportData": "Keine Berichtsdaten gefunden.",
|
||||||
|
"weeklyTab": "Wöchentlich",
|
||||||
|
"monthlyTab": "Monatlich",
|
||||||
|
"yearlyTab": "Jährlich",
|
||||||
|
"monthlyReportTitle": "Monatlicher Leistungsbericht",
|
||||||
|
"yearlyReportTitle": "Jährlicher Leistungsbericht",
|
||||||
|
"monthlyInsights": "Monatliche Einblicke",
|
||||||
|
"yearlyInsights": "Jährliche Einblicke",
|
||||||
|
"monthlySavings": "Ihre Einsparungen diesen Monat",
|
||||||
|
"yearlySavings": "Ihre Einsparungen dieses Jahr",
|
||||||
|
"monthlySummary": "Monatliche Zusammenfassung",
|
||||||
|
"yearlySummary": "Jährliche Zusammenfassung",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"weeksAggregated": "{count} Wochen aggregiert",
|
||||||
|
"monthsAggregated": "{count} Monate aggregiert",
|
||||||
|
"noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.",
|
||||||
|
"noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.",
|
||||||
|
"availableForGeneration": "Zur Generierung verfügbar",
|
||||||
|
"generateMonth": "{month} {year} generieren ({count} Wochen)",
|
||||||
|
"generateYear": "{year} generieren ({count} Monate)",
|
||||||
|
"generatingMonthly": "Wird generiert...",
|
||||||
|
"generatingYearly": "Wird generiert...",
|
||||||
|
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
||||||
"ai_analyzing": "KI analysiert...",
|
"ai_analyzing": "KI analysiert...",
|
||||||
"ai_show_details": "Details anzeigen",
|
"ai_show_details": "Details anzeigen",
|
||||||
"ai_show_less": "Weniger anzeigen",
|
"ai_show_less": "Weniger anzeigen",
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,28 @@
|
||||||
"confirmBatteryLogDownload": "Do you really want to download battery log?",
|
"confirmBatteryLogDownload": "Do you really want to download battery log?",
|
||||||
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
|
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
|
||||||
"noReportData": "No report data found.",
|
"noReportData": "No report data found.",
|
||||||
|
"weeklyTab": "Weekly",
|
||||||
|
"monthlyTab": "Monthly",
|
||||||
|
"yearlyTab": "Yearly",
|
||||||
|
"monthlyReportTitle": "Monthly Performance Report",
|
||||||
|
"yearlyReportTitle": "Annual Performance Report",
|
||||||
|
"monthlyInsights": "Monthly Insights",
|
||||||
|
"yearlyInsights": "Annual Insights",
|
||||||
|
"monthlySavings": "Your Savings This Month",
|
||||||
|
"yearlySavings": "Your Savings This Year",
|
||||||
|
"monthlySummary": "Monthly Summary",
|
||||||
|
"yearlySummary": "Annual Summary",
|
||||||
|
"total": "Total",
|
||||||
|
"weeksAggregated": "{count} weeks aggregated",
|
||||||
|
"monthsAggregated": "{count} months aggregated",
|
||||||
|
"noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.",
|
||||||
|
"noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.",
|
||||||
|
"availableForGeneration": "Available for Generation",
|
||||||
|
"generateMonth": "Generate {month} {year} ({count} weeks)",
|
||||||
|
"generateYear": "Generate {year} ({count} months)",
|
||||||
|
"generatingMonthly": "Generating...",
|
||||||
|
"generatingYearly": "Generating...",
|
||||||
|
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
||||||
"ai_analyzing": "AI is analyzing...",
|
"ai_analyzing": "AI is analyzing...",
|
||||||
"ai_show_details": "Show details",
|
"ai_show_details": "Show details",
|
||||||
"ai_show_less": "Show less",
|
"ai_show_less": "Show less",
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,28 @@
|
||||||
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
|
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
|
||||||
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
|
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
|
||||||
"noReportData": "Aucune donnée de rapport trouvée.",
|
"noReportData": "Aucune donnée de rapport trouvée.",
|
||||||
|
"weeklyTab": "Hebdomadaire",
|
||||||
|
"monthlyTab": "Mensuel",
|
||||||
|
"yearlyTab": "Annuel",
|
||||||
|
"monthlyReportTitle": "Rapport de performance mensuel",
|
||||||
|
"yearlyReportTitle": "Rapport de performance annuel",
|
||||||
|
"monthlyInsights": "Aperçus mensuels",
|
||||||
|
"yearlyInsights": "Aperçus annuels",
|
||||||
|
"monthlySavings": "Vos économies ce mois",
|
||||||
|
"yearlySavings": "Vos économies cette année",
|
||||||
|
"monthlySummary": "Résumé mensuel",
|
||||||
|
"yearlySummary": "Résumé annuel",
|
||||||
|
"total": "Total",
|
||||||
|
"weeksAggregated": "{count} semaines agrégées",
|
||||||
|
"monthsAggregated": "{count} mois agrégés",
|
||||||
|
"noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.",
|
||||||
|
"noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.",
|
||||||
|
"availableForGeneration": "Disponible pour génération",
|
||||||
|
"generateMonth": "Générer {month} {year} ({count} semaines)",
|
||||||
|
"generateYear": "Générer {year} ({count} mois)",
|
||||||
|
"generatingMonthly": "Génération en cours...",
|
||||||
|
"generatingYearly": "Génération en cours...",
|
||||||
|
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
||||||
"ai_analyzing": "L'IA analyse...",
|
"ai_analyzing": "L'IA analyse...",
|
||||||
"ai_show_details": "Afficher les détails",
|
"ai_show_details": "Afficher les détails",
|
||||||
"ai_show_less": "Afficher moins",
|
"ai_show_less": "Afficher moins",
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,28 @@
|
||||||
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
|
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
|
||||||
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
|
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
|
||||||
"noReportData": "Nessun dato del rapporto trovato.",
|
"noReportData": "Nessun dato del rapporto trovato.",
|
||||||
|
"weeklyTab": "Settimanale",
|
||||||
|
"monthlyTab": "Mensile",
|
||||||
|
"yearlyTab": "Annuale",
|
||||||
|
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
|
||||||
|
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
|
||||||
|
"monthlyInsights": "Approfondimenti mensili",
|
||||||
|
"yearlyInsights": "Approfondimenti annuali",
|
||||||
|
"monthlySavings": "I tuoi risparmi questo mese",
|
||||||
|
"yearlySavings": "I tuoi risparmi quest'anno",
|
||||||
|
"monthlySummary": "Riepilogo mensile",
|
||||||
|
"yearlySummary": "Riepilogo annuale",
|
||||||
|
"total": "Totale",
|
||||||
|
"weeksAggregated": "{count} settimane aggregate",
|
||||||
|
"monthsAggregated": "{count} mesi aggregati",
|
||||||
|
"noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.",
|
||||||
|
"noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.",
|
||||||
|
"availableForGeneration": "Disponibile per la generazione",
|
||||||
|
"generateMonth": "Genera {month} {year} ({count} settimane)",
|
||||||
|
"generateYear": "Genera {year} ({count} mesi)",
|
||||||
|
"generatingMonthly": "Generazione in corso...",
|
||||||
|
"generatingYearly": "Generazione in corso...",
|
||||||
|
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
||||||
"ai_analyzing": "L'IA sta analizzando...",
|
"ai_analyzing": "L'IA sta analizzando...",
|
||||||
"ai_show_details": "Mostra dettagli",
|
"ai_show_details": "Mostra dettagli",
|
||||||
"ai_show_less": "Mostra meno",
|
"ai_show_less": "Mostra meno",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue