a lot of fixes to report system
This commit is contained in:
parent
dc5b09d1f2
commit
706e0674fb
|
|
@ -961,6 +961,46 @@ public class Controller : ControllerBase
|
|||
|
||||
// ── Weekly Performance Report ──────────────────────────────────────
|
||||
|
||||
private async Task<WeeklyReportResponse?> FetchWeeklyReportAsync(
|
||||
Int64 installationId, String installationName, String lang,
|
||||
DateOnly? weekStartDate = null, Boolean forceRegenerate = false)
|
||||
{
|
||||
DateOnly periodStart, periodEnd;
|
||||
if (weekStartDate.HasValue)
|
||||
{
|
||||
periodStart = weekStartDate.Value;
|
||||
periodEnd = weekStartDate.Value.AddDays(6);
|
||||
}
|
||||
else
|
||||
{
|
||||
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
|
||||
}
|
||||
|
||||
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
|
||||
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
|
||||
|
||||
if (!forceRegenerate)
|
||||
{
|
||||
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
|
||||
if (cached != null)
|
||||
{
|
||||
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
|
||||
if (cachedResponse != null)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}");
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}");
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installationId, installationName, lang, weekStartDate);
|
||||
|
||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||
return report;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a weekly performance report. Serves from cache if available;
|
||||
/// generates fresh on first request or when forceRegenerate is true.
|
||||
|
|
@ -991,43 +1031,9 @@ public class Controller : ControllerBase
|
|||
{
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
|
||||
// Compute target week dates for cache lookup
|
||||
DateOnly periodStart, periodEnd;
|
||||
if (weekStartDate.HasValue)
|
||||
{
|
||||
periodStart = weekStartDate.Value;
|
||||
periodEnd = weekStartDate.Value.AddDays(6);
|
||||
}
|
||||
else
|
||||
{
|
||||
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
|
||||
}
|
||||
|
||||
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
|
||||
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
|
||||
|
||||
// Cache-first: check if a cached report exists for this week
|
||||
if (!forceRegenerate)
|
||||
{
|
||||
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
|
||||
if (cached != null)
|
||||
{
|
||||
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
|
||||
if (cachedResponse != null)
|
||||
{
|
||||
Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}");
|
||||
return Ok(cachedResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or forceRegenerate: generate fresh
|
||||
Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}");
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installationId, installation.Name, lang, weekStartDate);
|
||||
|
||||
// Persist weekly summary and seed AiInsightCache for this language
|
||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate, forceRegenerate);
|
||||
if (report == null)
|
||||
return BadRequest("Failed to generate report.");
|
||||
|
||||
return Ok(report);
|
||||
}
|
||||
|
|
@ -1134,6 +1140,46 @@ public class Controller : ControllerBase
|
|||
return Ok(reports);
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetCurrentMonthPreview))]
|
||||
public async Task<ActionResult<MonthlyReportSummary>> GetCurrentMonthPreview(
|
||||
Int64 installationId, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var preview = await ReportAggregationService.GetCurrentMonthPreviewAsync(installationId, lang);
|
||||
if (preview == null)
|
||||
return NotFound("No daily data for the current month.");
|
||||
|
||||
return Ok(preview);
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetCurrentYearPreview))]
|
||||
public async Task<ActionResult<YearlyReportSummary>> GetCurrentYearPreview(
|
||||
Int64 installationId, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var preview = await ReportAggregationService.GetCurrentYearPreviewAsync(installationId, lang);
|
||||
if (preview == null)
|
||||
return NotFound("No monthly reports for the current year.");
|
||||
|
||||
return Ok(preview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger monthly aggregation for an installation.
|
||||
/// Computes monthly report from daily records for the specified year/month.
|
||||
|
|
@ -1514,7 +1560,9 @@ public class Controller : ControllerBase
|
|||
// ── Report HTML (for PDF download) ─────────────────────────────
|
||||
|
||||
[HttpGet(nameof(GetWeeklyReportHtml))]
|
||||
public async Task<ActionResult> GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null)
|
||||
public async Task<ActionResult> GetWeeklyReportHtml(
|
||||
Int64 installationId, Token authToken,
|
||||
String? language = null, String? weekStart = null, String source = "email")
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
|
@ -1522,14 +1570,26 @@ public class Controller : ControllerBase
|
|||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
|
||||
var html = ReportEmailService.BuildHtmlEmail(report, lang);
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
|
||||
DateOnly? weekStartDate = null;
|
||||
if (!String.IsNullOrEmpty(weekStart))
|
||||
{
|
||||
if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed))
|
||||
return BadRequest("weekStart must be in yyyy-MM-dd format.");
|
||||
weekStartDate = parsed;
|
||||
}
|
||||
|
||||
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate);
|
||||
if (report == null)
|
||||
return BadRequest("Failed to generate report.");
|
||||
|
||||
var html = ReportEmailService.BuildHtmlEmail(report, lang, source: source);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetMonthlyReportHtml))]
|
||||
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
|
||||
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null, String source = "email")
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
|
@ -1548,12 +1608,12 @@ public class Controller : ControllerBase
|
|||
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);
|
||||
$"{report.WeekCount} {s.CountLabel}", s, source: source);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetYearlyReportHtml))]
|
||||
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null)
|
||||
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null, String source = "email")
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
|
@ -1572,7 +1632,7 @@ public class Controller : ControllerBase
|
|||
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);
|
||||
$"{report.MonthCount} {s.CountLabel}", s, source: source);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ public class MonthlyReportSummary
|
|||
public Int32 WeekCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
|
||||
// Preview-only fields (not stored in DB)
|
||||
[Ignore] public Boolean IsPreview { get; set; }
|
||||
[Ignore] public Int32 DaysAvailable { get; set; }
|
||||
[Ignore] public Int32 DaysInMonth { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -137,19 +142,22 @@ public class YearlyReportSummary
|
|||
public Int32 MonthCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
|
||||
// Preview-only fields (not stored in DB)
|
||||
[Ignore] public Boolean IsPreview { 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 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; }
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 MonthCount { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,8 +382,8 @@ public static class ReportAggregationService
|
|||
InstallationId = installationId,
|
||||
Year = year,
|
||||
Month = month,
|
||||
PeriodStart = first.ToString("yyyy-MM-dd"),
|
||||
PeriodEnd = last.ToString("yyyy-MM-dd"),
|
||||
PeriodStart = days.Min(d => d.Date), // actual first data day, not calendar month start
|
||||
PeriodEnd = days.Max(d => d.Date), // actual last data day, not calendar month end
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
|
|
@ -881,4 +881,132 @@ Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting ma
|
|||
|
||||
return "AI insight could not be generated at this time.";
|
||||
}
|
||||
|
||||
// ── Current-Period Previews (not saved to DB) ─────────────────────
|
||||
|
||||
public static async Task<MonthlyReportSummary?> GetCurrentMonthPreviewAsync(Int64 installationId, String language = "en")
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var year = now.Year;
|
||||
var month = now.Month;
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var days = Db.GetDailyRecords(installationId, first, last);
|
||||
|
||||
if (days.Count == 0)
|
||||
return null;
|
||||
|
||||
var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
|
||||
var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
|
||||
var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
|
||||
var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
|
||||
var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
|
||||
|
||||
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 installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
|
||||
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
|
||||
var aiInsight = await GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, days.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language,
|
||||
weatherCity, installation?.Country, weatherRegion);
|
||||
|
||||
var firstDataDay = days.Min(d => d.Date);
|
||||
var lastDataDay = days.Max(d => d.Date);
|
||||
|
||||
return new MonthlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
Month = month,
|
||||
PeriodStart = firstDataDay,
|
||||
PeriodEnd = lastDataDay,
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
WeekCount = days.Count,
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
IsPreview = true,
|
||||
DaysAvailable = days.Count,
|
||||
DaysInMonth = last.Day,
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<YearlyReportSummary?> GetCurrentYearPreviewAsync(Int64 installationId, String language = "en")
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||
|
||||
if (monthlies.Count == 0)
|
||||
return null;
|
||||
|
||||
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);
|
||||
|
||||
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 installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
|
||||
var aiInsight = await GenerateYearlyAiInsightAsync(
|
||||
installationName, year, monthlies.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language);
|
||||
|
||||
return 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,
|
||||
MonthCount = monthlies.Count,
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
IsPreview = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -40,6 +40,33 @@ public static class WeatherService
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns historical weather for a date range, or null on any failure.
|
||||
/// Uses Open-Meteo's archive API for past weather data.
|
||||
/// </summary>
|
||||
public static async Task<List<DailyWeather>?> GetHistoricalAsync(
|
||||
string? city, string? country, string? region,
|
||||
DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(city))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var coords = await GeocodeAsync(city, region);
|
||||
if (coords == null)
|
||||
return null;
|
||||
|
||||
var (lat, lon) = coords.Value;
|
||||
return await FetchHistoricalAsync(lat, lon, startDate, endDate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WeatherService] Error fetching historical weather for '{city}': {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a forecast list into a compact text block for AI prompt injection.
|
||||
/// </summary>
|
||||
|
|
@ -52,7 +79,22 @@ public static class WeatherService
|
|||
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||||
});
|
||||
|
||||
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
|
||||
return "WEATHER FORECAST (coming 7 days):\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats historical weather into a compact text block for AI prompt injection.
|
||||
/// </summary>
|
||||
public static string FormatHistoricalForPrompt(List<DailyWeather> historical)
|
||||
{
|
||||
var lines = historical.Select(d =>
|
||||
{
|
||||
var date = DateTime.Parse(d.Date);
|
||||
var dayName = date.ToString("ddd dd MMM");
|
||||
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||||
});
|
||||
|
||||
return "ACTUAL WEATHER (during reporting week):\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -145,6 +187,44 @@ public static class WeatherService
|
|||
return forecast;
|
||||
}
|
||||
|
||||
private static async Task<List<DailyWeather>?> FetchHistoricalAsync(double lat, double lon, DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
var url = $"https://archive-api.open-meteo.com/v1/archive"
|
||||
+ $"?latitude={lat}&longitude={lon}"
|
||||
+ $"&start_date={startDate:yyyy-MM-dd}&end_date={endDate:yyyy-MM-dd}"
|
||||
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
|
||||
+ "&timezone=Europe/Zurich";
|
||||
|
||||
var json = await url.GetStringAsync();
|
||||
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||||
|
||||
if (data?.daily == null)
|
||||
return null;
|
||||
|
||||
var dates = data.daily.time;
|
||||
var tempMax = data.daily.temperature_2m_max;
|
||||
var tempMin = data.daily.temperature_2m_min;
|
||||
var sun = data.daily.sunshine_duration;
|
||||
var precip = data.daily.precipitation_sum;
|
||||
var codes = data.daily.weathercode;
|
||||
|
||||
var historical = new List<DailyWeather>();
|
||||
for (int i = 0; i < dates.Count; i++)
|
||||
{
|
||||
historical.Add(new DailyWeather(
|
||||
Date: (string)dates[i],
|
||||
TempMin: (double)tempMin[i],
|
||||
TempMax: (double)tempMax[i],
|
||||
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
|
||||
PrecipitationMm: (double)precip[i],
|
||||
Description: WeatherCodeToDescription((int)codes[i])
|
||||
));
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WeatherService] Fetched {historical.Count}-day historical weather ({startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}).");
|
||||
return historical;
|
||||
}
|
||||
|
||||
private static string WeatherCodeToDescription(int code) => code switch
|
||||
{
|
||||
0 => "Clear sky",
|
||||
|
|
|
|||
|
|
@ -274,7 +274,8 @@ public static class WeeklyReportService
|
|||
var aiInsight = await GetAiInsightAsync(
|
||||
currentWeekDays, currentSummary, previousSummary,
|
||||
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
||||
behavior, installationName, language, location, country, region);
|
||||
behavior, installationName, language,
|
||||
weekStart, weekEnd, location, country, region);
|
||||
|
||||
// Compute data availability — which days of the week are missing
|
||||
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
|
||||
|
|
@ -356,6 +357,8 @@ public static class WeeklyReportService
|
|||
BehavioralPattern behavior,
|
||||
string installationName,
|
||||
string language = "en",
|
||||
DateOnly? periodStart = null,
|
||||
DateOnly? periodEnd = null,
|
||||
string? location = null,
|
||||
string? country = null,
|
||||
string? region = null)
|
||||
|
|
@ -367,7 +370,23 @@ public static class WeeklyReportService
|
|||
return "AI insight unavailable (API key not configured).";
|
||||
}
|
||||
|
||||
// Fetch weather forecast for the installation's location
|
||||
// Date labels for prompt clarity
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var periodLabel = periodStart.HasValue && periodEnd.HasValue
|
||||
? $"{periodStart.Value:MMM dd}–{periodEnd.Value:MMM dd}"
|
||||
: "the reporting week";
|
||||
|
||||
// Fetch historical weather for the report week (actual conditions)
|
||||
List<WeatherService.DailyWeather>? historical = null;
|
||||
var historicalBlock = "";
|
||||
if (periodStart.HasValue && periodEnd.HasValue)
|
||||
{
|
||||
historical = await WeatherService.GetHistoricalAsync(location, country, region, periodStart.Value, periodEnd.Value);
|
||||
historicalBlock = historical != null ? "\n" + WeatherService.FormatHistoricalForPrompt(historical) + "\n" : "";
|
||||
Console.WriteLine($"[WeeklyReportService] Historical weather: {(historical != null ? $"{historical.Count} days fetched" : "SKIPPED (no location or API error)")}");
|
||||
}
|
||||
|
||||
// Fetch weather forecast for the coming week
|
||||
var forecast = await WeatherService.GetForecastAsync(location, country, region);
|
||||
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
|
||||
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
|
||||
|
|
@ -399,22 +418,29 @@ public static class WeeklyReportService
|
|||
var battDepleteLine = hasBattery
|
||||
? (behavior.AvgBatteryDepletedHour >= 0
|
||||
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
|
||||
: "Battery stayed above 20% SoC every night this week.")
|
||||
: $"Battery stayed above 20% SoC every night during {periodLabel}.")
|
||||
: "";
|
||||
|
||||
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
|
||||
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
|
||||
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
|
||||
|
||||
// Look up actual weather for best/worst solar days (if historical data available)
|
||||
var bestDayWeather = historical?.FirstOrDefault(w => w.Date == bestDay.Date);
|
||||
var worstDayWeather = historical?.FirstOrDefault(w => w.Date == worstDay.Date);
|
||||
|
||||
var bestDayWeatherNote = bestDayWeather != null ? $" (actual weather: {bestDayWeather.Description}, {bestDayWeather.SunshineHours:F1}h sunshine)" : "";
|
||||
var worstDayWeatherNote = worstDayWeather != null ? $" (actual weather: {worstDayWeather.Description}, {worstDayWeather.SunshineHours:F1}h sunshine)" : "";
|
||||
|
||||
// Build conditional fact lines
|
||||
var pvDailyFact = hasPv
|
||||
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
|
||||
? $"- PV: total {current.TotalPvProduction:F1} kWh for {periodLabel}. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh){bestDayWeatherNote}, worst: {worstDayName} ({worstDay.PvProduction:F1} kWh){worstDayWeatherNote}. Solar covered {selfSufficiency}% of consumption."
|
||||
: "";
|
||||
var battDailyFact = hasBattery
|
||||
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
|
||||
: "";
|
||||
var gridDailyFact = hasGrid
|
||||
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
|
||||
? $"- Grid import: {current.TotalGridImport:F1} kWh total for {periodLabel}."
|
||||
: "";
|
||||
|
||||
// Behavioral section — only include when hourly data exists
|
||||
|
|
@ -432,7 +458,7 @@ public static class WeeklyReportService
|
|||
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
|
||||
|
||||
behavioralSection = $@"
|
||||
BEHAVIORAL PATTERN (from hourly data this week):
|
||||
BEHAVIORAL PATTERN (from hourly data for {periodLabel}):
|
||||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||||
- {weekdayWeekendLine}{pvBehaviorLines}
|
||||
{gridBehaviorLine}
|
||||
|
|
@ -440,12 +466,15 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
}
|
||||
|
||||
// Build conditional instructions
|
||||
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
|
||||
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that during {periodLabel}, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
|
||||
|
||||
var hasHistorical = historical != null && historical.Count > 0;
|
||||
var instruction2 = hasPv
|
||||
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
|
||||
? hasHistorical
|
||||
? $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Use the ACTUAL WEATHER data provided above to explain why — do NOT guess the weather. Reference the real conditions (sunshine hours, weather description) from the historical weather data."
|
||||
: $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Only state the production numbers — do NOT speculate about weather reasons if no weather data is provided."
|
||||
: hasGrid
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid during {periodLabel}."
|
||||
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
|
||||
|
||||
var instruction3 = hasBattery
|
||||
|
|
@ -455,19 +484,24 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
// Instruction 4 — adapts based on whether we have behavioral data
|
||||
string instruction4;
|
||||
if (hasBehavior && hasPv)
|
||||
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
|
||||
instruction4 = $"4. Smart action for the coming week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
|
||||
else if (hasBehavior && hasGrid)
|
||||
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
|
||||
instruction4 = $"4. Smart action for the coming week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
|
||||
else
|
||||
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
|
||||
instruction4 = $"4. Smart action for the coming week: Based on the energy patterns from {periodLabel}, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
|
||||
|
||||
// Instruction 5 — weather outlook with pattern-based predictions
|
||||
var hasWeather = forecast != null;
|
||||
var bulletCount = hasWeather ? 5 : 4;
|
||||
// Forecast date range label for prompt
|
||||
var forecastLabel = forecast != null && forecast.Count > 0
|
||||
? $"{DateTime.Parse(forecast.First().Date):MMM dd}–{DateTime.Parse(forecast.Last().Date):MMM dd}"
|
||||
: "the coming days";
|
||||
|
||||
var instruction5 = "";
|
||||
if (hasWeather && hasPv)
|
||||
{
|
||||
// Compute avg daily PV production this week for reference
|
||||
// Compute avg daily PV production for the reporting week as reference
|
||||
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
|
||||
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
|
||||
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
|
||||
|
|
@ -477,36 +511,39 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
|
||||
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
|
||||
|
||||
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
|
||||
var patternContext = $"During {periodLabel} the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
|
||||
if (sunnyDays.Count > 0)
|
||||
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
|
||||
patternContext += $"In the coming days ({forecastLabel}), sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
|
||||
if (cloudyDays.Count > 0)
|
||||
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
|
||||
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
|
||||
patternContext += $"Total forecast sunshine for {forecastLabel}: {totalForecastSunshine}h.";
|
||||
|
||||
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
|
||||
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days in the coming days ({forecastLabel}) and estimate production based on the reporting week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
|
||||
}
|
||||
else if (hasWeather)
|
||||
{
|
||||
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
|
||||
instruction5 = $@"5. Weather outlook: Summarize the weather for the coming days ({forecastLabel}) in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
|
||||
}
|
||||
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
Today is {today:yyyy-MM-dd} ({today:dddd}). This report covers the week of {periodLabel}.
|
||||
|
||||
Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||
|
||||
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
|
||||
|
||||
CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number.
|
||||
CRITICAL: Use explicit date references. Say ""during {periodLabel}"" for the reporting week. Say ""the coming days ({forecastLabel})"" for the forecast period. NEVER use ambiguous terms like ""this week"" or ""next week"".
|
||||
|
||||
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
|
||||
|
||||
DAILY FACTS:
|
||||
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
|
||||
DAILY FACTS (for {periodLabel}):
|
||||
- Total consumption: {current.TotalConsumption:F1} kWh. Self-sufficiency: {selfSufficiency}%.
|
||||
{pvDailyFact}
|
||||
{battDailyFact}
|
||||
{gridDailyFact}
|
||||
{behavioralSection}
|
||||
{historicalBlock}
|
||||
{weatherBlock}
|
||||
INSTRUCTIONS:
|
||||
{instruction1}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -56,19 +56,6 @@ interface HourlyEnergyRecord {
|
|||
|
||||
// ── Date Helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the Monday of the current week.
|
||||
*/
|
||||
function getCurrentMonday(): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dow = today.getDay(); // 0=Sun
|
||||
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - offset);
|
||||
return monday;
|
||||
}
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
|
|
@ -77,19 +64,20 @@ function formatDateISO(d: Date): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns current week Mon→yesterday. Today excluded because
|
||||
* S3 aggregated file is not available until end of day.
|
||||
* Returns the last 7 days ending yesterday.
|
||||
* Today is excluded because S3 aggregated file is not available until ~01:00 UTC the next day.
|
||||
*/
|
||||
function getCurrentWeekDays(currentMonday: Date): Date[] {
|
||||
function getLast7Days(): Date[] {
|
||||
const yesterday = new Date();
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const days: Date[] = [];
|
||||
|
||||
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) {
|
||||
days.push(new Date(d));
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(yesterday);
|
||||
d.setDate(yesterday.getDate() - i);
|
||||
days.push(d);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +93,6 @@ export default function DailySection({
|
|||
onPeriodChange?: (date: string) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
||||
const yesterday = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
|
|
@ -125,11 +112,8 @@ export default function DailySection({
|
|||
const [loadingWeek, setLoadingWeek] = useState(false);
|
||||
const [noData, setNoData] = useState(false);
|
||||
|
||||
// Current week Mon→yesterday only
|
||||
const weekDays = useMemo(
|
||||
() => getCurrentWeekDays(currentMonday),
|
||||
[currentMonday]
|
||||
);
|
||||
// Rolling 7-day window ending yesterday
|
||||
const weekDays = useMemo(() => getLast7Days(), []);
|
||||
|
||||
// Fetch data for current week days
|
||||
useEffect(() => {
|
||||
|
|
@ -193,7 +177,7 @@ export default function DailySection({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Day Strip — current week Mon→yesterday */}
|
||||
{/* Day Strip — last 7 days ending yesterday */}
|
||||
<DayStrip
|
||||
weekDays={weekDays}
|
||||
selectedDate={selectedDate}
|
||||
|
|
@ -344,7 +328,7 @@ function DayStrip({
|
|||
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||
<FormattedMessage
|
||||
id="currentWeekHint"
|
||||
defaultMessage="Current week (Mon–yesterday)"
|
||||
defaultMessage="Last 7 days"
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -388,13 +372,14 @@ function IntradayChart({
|
|||
|
||||
const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
|
||||
|
||||
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null);
|
||||
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null);
|
||||
const getHour = (i: number) => hourMap.get(i === 24 ? 23 : i);
|
||||
const pvData = HOUR_LABELS.map((_, i) => getHour(i)?.pvKwh ?? null);
|
||||
const loadData = HOUR_LABELS.map((_, i) => getHour(i)?.loadKwh ?? null);
|
||||
const batteryData = HOUR_LABELS.map((_, i) => {
|
||||
const h = hourMap.get(i);
|
||||
const h = getHour(i);
|
||||
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
|
||||
});
|
||||
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null);
|
||||
const socData = HOUR_LABELS.map((_, i) => getHour(i)?.battSoC ?? null);
|
||||
|
||||
const chartData = {
|
||||
labels: HOUR_LABELS,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import SendIcon from '@mui/icons-material/Send';
|
|||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import DailySection from './DailySection';
|
||||
|
||||
|
|
@ -104,6 +105,9 @@ interface MonthlyReport extends ReportSummary {
|
|||
avgPeakSolarHour: number;
|
||||
avgWeekdayDailyLoad: number;
|
||||
avgWeekendDailyLoad: number;
|
||||
isPreview?: boolean;
|
||||
daysAvailable?: number;
|
||||
daysInMonth?: number;
|
||||
}
|
||||
|
||||
interface YearlyReport extends ReportSummary {
|
||||
|
|
@ -113,17 +117,7 @@ interface YearlyReport extends ReportSummary {
|
|||
avgPeakSolarHour: number;
|
||||
avgWeekdayDailyLoad: number;
|
||||
avgWeekendDailyLoad: number;
|
||||
}
|
||||
|
||||
interface PendingMonth {
|
||||
year: number;
|
||||
month: number;
|
||||
weekCount: number;
|
||||
}
|
||||
|
||||
interface PendingYear {
|
||||
year: number;
|
||||
monthCount: number;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
interface WeeklyReportSummaryRecord {
|
||||
|
|
@ -151,6 +145,49 @@ interface WeeklyReportSummaryRecord {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
function ReportHtmlFrame({ html }: { html: string }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(600);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
try {
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (doc?.body) {
|
||||
const newHeight = doc.body.scrollHeight + 20;
|
||||
if (newHeight > 50) setHeight(newHeight);
|
||||
}
|
||||
} catch { /* cross-origin safety */ }
|
||||
};
|
||||
|
||||
iframe.addEventListener('load', updateHeight);
|
||||
const timers = [300, 800, 1500].map(ms => setTimeout(updateHeight, ms));
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener('load', updateHeight);
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={html}
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
sandbox="allow-same-origin"
|
||||
title="report"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34)
|
||||
const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
|
||||
const isBold = (s: string) => /\d/.test(s);
|
||||
|
|
@ -234,15 +271,15 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
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 [monthlyPreview, setMonthlyPreview] = useState<MonthlyReport | null>(null);
|
||||
const [yearlyPreview, setYearlyPreview] = useState<YearlyReport | null>(null);
|
||||
const [selectedMonthlyIdx, setSelectedMonthlyIdx] = useState(0);
|
||||
const [selectedYearlyIdx, setSelectedYearlyIdx] = useState(0);
|
||||
const [dailyHasData, setDailyHasData] = useState(false);
|
||||
const [weeklyHasData, setWeeklyHasData] = useState(false);
|
||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||
const [reportPeriod, setReportPeriod] = useState<{ start: string; end: string; year?: number; month?: number } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [reportPeriods, setReportPeriods] = useState<Record<string, { start: string; end: string; year?: number; month?: number }>>({});
|
||||
const weeklyRef = useRef<WeeklySectionHandle>(null);
|
||||
|
||||
// Auto-send email preferences
|
||||
|
|
@ -290,44 +327,16 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
axiosConfig.get('/GetYearlyReports', { params: { installationId, language: lang } })
|
||||
.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(() => {});
|
||||
axiosConfig.get('/GetCurrentMonthPreview', { params: { installationId, language: lang } })
|
||||
.then(res => setMonthlyPreview(res.data))
|
||||
.catch(() => setMonthlyPreview(null));
|
||||
axiosConfig.get('/GetCurrentYearPreview', { params: { installationId, language: lang } })
|
||||
.then(res => setYearlyPreview(res.data))
|
||||
.catch(() => setYearlyPreview(null));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchReportData(); }, [installationId, intl.locale]);
|
||||
|
||||
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: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
|
||||
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
|
||||
|
|
@ -341,8 +350,8 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
const key = tabs[safeTab]?.key;
|
||||
if (key === 'daily') return dailyHasData;
|
||||
if (key === 'weekly') return weeklyHasData;
|
||||
if (key === 'monthly') return monthlyReports.length > 0;
|
||||
if (key === 'yearly') return yearlyReports.length > 0;
|
||||
if (key === 'monthly') return monthlyReports.length > 0 || monthlyPreview !== null;
|
||||
if (key === 'yearly') return yearlyReports.length > 0 || yearlyPreview !== null;
|
||||
return false;
|
||||
})();
|
||||
|
||||
|
|
@ -354,19 +363,19 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
switch (reportType) {
|
||||
case 'daily':
|
||||
endpoint = '/GetDailyReportHtml';
|
||||
if (reportPeriod?.start) params.date = reportPeriod.start;
|
||||
if (reportPeriods.daily?.start) params.date = reportPeriods.daily.start;
|
||||
break;
|
||||
case 'weekly':
|
||||
endpoint = '/GetWeeklyReportHtml';
|
||||
break;
|
||||
case 'monthly':
|
||||
endpoint = '/GetMonthlyReportHtml';
|
||||
if (reportPeriod?.year) params.year = reportPeriod.year;
|
||||
if (reportPeriod?.month) params.month = reportPeriod.month;
|
||||
if (reportPeriods.monthly?.year) params.year = reportPeriods.monthly.year;
|
||||
if (reportPeriods.monthly?.month) params.month = reportPeriods.monthly.month;
|
||||
break;
|
||||
case 'yearly':
|
||||
endpoint = '/GetYearlyReportHtml';
|
||||
if (reportPeriod?.year) params.year = reportPeriod.year;
|
||||
if (reportPeriods.yearly?.year) params.year = reportPeriods.yearly.year;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -378,17 +387,26 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
const dateRange = reportPeriod
|
||||
? `${reportPeriod.start.replace(/-/g, '')}-${reportPeriod.end.replace(/-/g, '')}`
|
||||
const activePeriod = reportPeriods[reportType] ?? null;
|
||||
const dateRange = activePeriod
|
||||
? `${activePeriod.start.replace(/-/g, '')}-${activePeriod.end.replace(/-/g, '')}`
|
||||
: new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||
|
||||
const safeName = (installationName || String(installationId))
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9\-_]/g, '');
|
||||
|
||||
printWindow.document.write(res.data);
|
||||
const pdfTitle = `${safeName}-${reportType}-${dateRange}`;
|
||||
let html = res.data as string;
|
||||
if (html.includes('<head>')) {
|
||||
html = html.replace('<head>', `<head><title>${pdfTitle}</title>`);
|
||||
} else if (html.includes('<html>')) {
|
||||
html = html.replace('<html>', `<html><head><title>${pdfTitle}</title></head>`);
|
||||
}
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.document.title = `${safeName}-${reportType}-${dateRange}`;
|
||||
printWindow.document.title = pdfTitle;
|
||||
printWindow.onafterprint = () => printWindow.close();
|
||||
const imgs = printWindow.document.querySelectorAll('img');
|
||||
const allLoaded = Array.from(imgs).map(img =>
|
||||
|
|
@ -402,6 +420,36 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
}
|
||||
};
|
||||
|
||||
const handleRefreshReport = async () => {
|
||||
const reportType = tabs[safeTab]?.key ?? '';
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (reportType === 'weekly') {
|
||||
await weeklyRef.current?.regenerate();
|
||||
} else if (reportType === 'monthly') {
|
||||
const period = reportPeriods.monthly;
|
||||
if (period?.year && period?.month) {
|
||||
await axiosConfig.post('/TriggerMonthlyAggregation', null, {
|
||||
params: { installationId, year: period.year, month: period.month }
|
||||
});
|
||||
fetchReportData();
|
||||
}
|
||||
} else if (reportType === 'yearly') {
|
||||
const period = reportPeriods.yearly;
|
||||
if (period?.year) {
|
||||
await axiosConfig.post('/TriggerYearlyAggregation', null, {
|
||||
params: { installationId, year: period.year }
|
||||
});
|
||||
fetchReportData();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Report refresh failed', err);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
|
||||
|
|
@ -412,12 +460,23 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
>
|
||||
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||
</Tabs>
|
||||
{tabs[safeTab]?.key !== 'daily' && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={refreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
|
||||
onClick={handleRefreshReport}
|
||||
disabled={refreshing || downloadingPdf}
|
||||
sx={{ ml: 2, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<FormattedMessage id={refreshing ? 'refreshing' : 'refreshReport'} defaultMessage={refreshing ? 'Refreshing...' : 'Refresh Report'} />
|
||||
</Button>
|
||||
)}
|
||||
{activeTabHasData && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={downloadingPdf ? <CircularProgress size={16} /> : <DownloadIcon />}
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={downloadingPdf}
|
||||
disabled={downloadingPdf || refreshing}
|
||||
sx={{ ml: 2, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
|
||||
|
|
@ -478,7 +537,7 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
/>
|
||||
|
||||
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||
<DailySection installationId={installationId} onHasData={setDailyHasData} onPeriodChange={(date: string) => setReportPeriod({ start: date, end: date })} />
|
||||
<DailySection installationId={installationId} onHasData={setDailyHasData} onPeriodChange={(date: string) => setReportPeriods(prev => ({ ...prev, daily: { start: date, end: date } }))} />
|
||||
</Box>
|
||||
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||
<WeeklySection
|
||||
|
|
@ -490,31 +549,27 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
: null
|
||||
}
|
||||
onHasData={setWeeklyHasData}
|
||||
onPeriodChange={(start, end) => setReportPeriod({ start, end })}
|
||||
onPeriodChange={(start, end) => setReportPeriods(prev => ({ ...prev, weekly: { start, end } }))}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||
<MonthlySection
|
||||
installationId={installationId}
|
||||
reports={monthlyReports}
|
||||
pendingMonths={pendingMonths}
|
||||
generating={generating}
|
||||
onGenerate={handleGenerateMonthly}
|
||||
preview={monthlyPreview}
|
||||
selectedIdx={selectedMonthlyIdx}
|
||||
onSelectedIdxChange={setSelectedMonthlyIdx}
|
||||
onPeriodChange={(r: MonthlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month })}
|
||||
onPeriodChange={(r: MonthlyReport) => setReportPeriods(prev => ({ ...prev, monthly: { start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month } }))}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||
<YearlySection
|
||||
installationId={installationId}
|
||||
reports={yearlyReports}
|
||||
pendingYears={pendingYears}
|
||||
generating={generating}
|
||||
onGenerate={handleGenerateYearly}
|
||||
preview={yearlyPreview}
|
||||
selectedIdx={selectedYearlyIdx}
|
||||
onSelectedIdxChange={setSelectedYearlyIdx}
|
||||
onPeriodChange={(r: YearlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year })}
|
||||
onPeriodChange={(r: YearlyReport) => setReportPeriods(prev => ({ ...prev, yearly: { start: r.periodStart, end: r.periodEnd, year: r.year } }))}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -524,7 +579,7 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
|
|||
// ── Weekly Section (existing weekly report content) ────────────
|
||||
|
||||
interface WeeklySectionHandle {
|
||||
regenerate: () => void;
|
||||
regenerate: () => Promise<void>;
|
||||
}
|
||||
|
||||
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void; onPeriodChange?: (start: string, end: string) => void }>(
|
||||
|
|
@ -533,6 +588,7 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
|
|||
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reportHtml, setReportHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
|
|
@ -542,12 +598,35 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await axiosConfig.get('/GetWeeklyReport', {
|
||||
params: { installationId, language: intl.locale, forceRegenerate }
|
||||
});
|
||||
setReport(res.data);
|
||||
onHasData?.(true);
|
||||
onPeriodChange?.(res.data.periodStart, res.data.periodEnd);
|
||||
// When regenerating, fetch JSON first (populates cache), then HTML (reads fresh cache)
|
||||
// When not regenerating, fetch both in parallel (both read from cache)
|
||||
if (forceRegenerate) {
|
||||
const jsonRes = await axiosConfig.get('/GetWeeklyReport', {
|
||||
params: { installationId, language: intl.locale, forceRegenerate }
|
||||
});
|
||||
const htmlRes = await axiosConfig.get('/GetWeeklyReportHtml', {
|
||||
params: { installationId, language: intl.locale, source: 'web' },
|
||||
responseType: 'text'
|
||||
});
|
||||
setReport(jsonRes.data);
|
||||
setReportHtml(htmlRes.data);
|
||||
onHasData?.(true);
|
||||
onPeriodChange?.(jsonRes.data.periodStart, jsonRes.data.periodEnd);
|
||||
} else {
|
||||
const [jsonRes, htmlRes] = await Promise.all([
|
||||
axiosConfig.get('/GetWeeklyReport', {
|
||||
params: { installationId, language: intl.locale }
|
||||
}),
|
||||
axiosConfig.get('/GetWeeklyReportHtml', {
|
||||
params: { installationId, language: intl.locale, source: 'web' },
|
||||
responseType: 'text'
|
||||
})
|
||||
]);
|
||||
setReport(jsonRes.data);
|
||||
setReportHtml(htmlRes.data);
|
||||
onHasData?.(true);
|
||||
onPeriodChange?.(jsonRes.data.periodStart, jsonRes.data.periodEnd);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err.response?.data ||
|
||||
|
|
@ -599,206 +678,15 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
|
|||
|
||||
if (!report) return null;
|
||||
|
||||
const cur = report.currentWeek;
|
||||
const prev = report.previousWeek;
|
||||
|
||||
const currentWeekDayCount = Math.min(7, report.dailyData.length);
|
||||
const previousWeekDayCount = 7;
|
||||
|
||||
const formatChange = (pct: number) =>
|
||||
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
|
||||
|
||||
const changeColor = (pct: number, invert = false) => {
|
||||
const effective = invert ? -pct : pct;
|
||||
return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
|
||||
};
|
||||
|
||||
// Compute change % from two raw values; returns 0 (shown as —) if prev is 0
|
||||
const calcChange = (curVal: number, prevVal: number) =>
|
||||
prevVal === 0 ? 0 : ((curVal - prevVal) / prevVal) * 100;
|
||||
|
||||
const insightBullets = report.aiInsight
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const totalEnergySavedKwh = report.totalEnergySaved;
|
||||
const totalSavingsCHF = report.totalSavingsCHF;
|
||||
|
||||
const maxDailyValue = Math.max(
|
||||
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
|
||||
1
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Email bar */}
|
||||
<EmailBar onSend={handleSendEmail} />
|
||||
|
||||
{/* Report Header */}
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: '#2c3e50',
|
||||
color: '#fff',
|
||||
p: 3,
|
||||
mb: 3,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
|
||||
{report.installationName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||
{report.periodStart} — {report.periodEnd}
|
||||
</Typography>
|
||||
</Paper>
|
||||
{/* Report content — rendered from backend HTML (single source of truth) */}
|
||||
{reportHtml && <ReportHtmlFrame html={reportHtml} />}
|
||||
|
||||
{/* Missing days warning */}
|
||||
{report.missingDates && report.missingDates.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
<FormattedMessage
|
||||
id="missingDaysWarning"
|
||||
defaultMessage="Data available for {available}/{expected} days. Missing: {dates}"
|
||||
values={{
|
||||
available: report.daysAvailable,
|
||||
expected: report.daysExpected,
|
||||
dates: report.missingDates.join(', ')
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Weekly Insights */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
|
||||
</Typography>
|
||||
<InsightBox text={report.aiInsight} bullets={insightBullets} />
|
||||
</Paper>
|
||||
|
||||
{/* Your Savings This Week */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
|
||||
</Typography>
|
||||
<SavingsCards
|
||||
intl={intl}
|
||||
energySaved={totalEnergySavedKwh}
|
||||
savingsCHF={totalSavingsCHF}
|
||||
selfSufficiency={report.selfSufficiencyPercent}
|
||||
batteryEfficiency={report.batteryEfficiencyPercent}
|
||||
hint={report.daysEquivalent > 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Weekly Summary Table */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||
<FormattedMessage id="weeklySummary" defaultMessage="Weekly 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="thisWeek" defaultMessage="This Week" /></th>
|
||||
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="lastWeek" defaultMessage="Last Week" /></th>}
|
||||
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="change" defaultMessage="Change" /></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalPvProduction.toFixed(1)} kWh</td>
|
||||
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalPvProduction.toFixed(1)} kWh</td>}
|
||||
{prev && <td style={{ textAlign: 'right', color: changeColor(report.pvChangePercent) }}>{formatChange(report.pvChangePercent)}</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalConsumption.toFixed(1)} kWh</td>
|
||||
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalConsumption.toFixed(1)} kWh</td>}
|
||||
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
|
||||
</tr>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
<td style={{ color: '#888', paddingLeft: '20px', fontSize: '13px' }}>
|
||||
<FormattedMessage id="avgDailyConsumption" defaultMessage="Avg Daily Consumption" />
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', color: '#888', fontSize: '13px' }}>
|
||||
{(cur.totalConsumption / currentWeekDayCount).toFixed(1)} kWh
|
||||
</td>
|
||||
{prev && <td style={{ textAlign: 'right', color: '#bbb', fontSize: '13px' }}>
|
||||
{(prev.totalConsumption / previousWeekDayCount).toFixed(1)} kWh
|
||||
</td>}
|
||||
{prev && <td />}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridImport.toFixed(1)} kWh</td>
|
||||
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridImport.toFixed(1)} kWh</td>}
|
||||
{prev && <td style={{ textAlign: 'right', color: changeColor(report.gridImportChangePercent, true) }}>{formatChange(report.gridImportChangePercent)}</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridExport.toFixed(1)} kWh</td>
|
||||
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridExport.toFixed(1)} kWh</td>}
|
||||
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{formatChange(calcChange(cur.totalGridExport, prev.totalGridExport))}</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh</td>
|
||||
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh</td>}
|
||||
{prev && <td style={{ textAlign: 'right', color: changeColor(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged)) }}>{formatChange(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged))}</td>}
|
||||
</tr>
|
||||
</tbody>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Daily Breakdown */}
|
||||
{report.dailyData.length > 0 && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
|
||||
<FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" />
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}>
|
||||
<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>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, bgcolor: '#3498db', borderRadius: '2px' }} /> <FormattedMessage id="consumption" defaultMessage="Consumption" />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
|
||||
</Box>
|
||||
</Box>
|
||||
{report.dailyData.map((d, i) => {
|
||||
const dt = new Date(d.date);
|
||||
const dayLabel = dt.toLocaleDateString(intl.locale, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true;
|
||||
return (
|
||||
<Box key={d.date} sx={{ mb: 1.5, opacity: isCurrentWeek ? 1 : 0.6 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: isCurrentWeek ? 'bold' : 'normal', color: '#444', fontSize: '12px' }}>
|
||||
{dayLabel}
|
||||
{!isCurrentWeek && <span style={{ color: '#999', marginLeft: 4 }}><FormattedMessage id="prevWeek" defaultMessage="(prev week)" /></span>}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#888', fontSize: '11px' }}>
|
||||
{intl.formatMessage({ id: 'pvProduction' })} {d.pvProduction.toFixed(1)} | {intl.formatMessage({ id: 'consumption' })} {d.loadConsumption.toFixed(1)} | {intl.formatMessage({ id: 'gridImport' })} {d.gridImport.toFixed(1)} kWh
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
|
||||
<Box sx={{ width: `${(d.pvProduction / maxDailyValue) * 100}%`, 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>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Weekly History — weeks not yet captured in a monthly report, excluding the current week shown above */}
|
||||
{/* Weekly History — weeks not yet captured in a monthly report */}
|
||||
<WeeklyHistory
|
||||
installationId={installationId}
|
||||
latestMonthlyPeriodEnd={latestMonthlyPeriodEnd}
|
||||
|
|
@ -928,57 +816,85 @@ function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPe
|
|||
function MonthlySection({
|
||||
installationId,
|
||||
reports,
|
||||
pendingMonths,
|
||||
generating,
|
||||
onGenerate,
|
||||
preview,
|
||||
selectedIdx,
|
||||
onSelectedIdxChange,
|
||||
onPeriodChange
|
||||
}: {
|
||||
installationId: number;
|
||||
reports: MonthlyReport[];
|
||||
pendingMonths: PendingMonth[];
|
||||
generating: string | null;
|
||||
onGenerate: (year: number, month: number) => void;
|
||||
preview: MonthlyReport | null;
|
||||
selectedIdx: number;
|
||||
onSelectedIdxChange: (idx: number) => void;
|
||||
onPeriodChange?: (report: MonthlyReport) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
// Check if current month already has a finalized report
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const hasFinalizedCurrentMonth = reports.some(r => r.year === currentYear && r.month === currentMonth);
|
||||
|
||||
// Show preview only if no finalized report for current month exists
|
||||
const showPreview = preview && !hasFinalizedCurrentMonth;
|
||||
|
||||
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: getMonthName(p.month, intl.locale), year: p.year, count: p.weekCount }
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{/* Current month preview */}
|
||||
{showPreview && (
|
||||
<Paper sx={{ mb: 3, border: '1px solid #f0ad4e', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Box sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, borderRadius: '8px 8px 0 0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
<FormattedMessage id="monthlyReportTitle" defaultMessage="Monthly Performance Report" />
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ bgcolor: '#f0ad4e', color: '#000', px: 1, py: 0.3, borderRadius: 1, fontWeight: 'bold' }}>
|
||||
<FormattedMessage id="reportInProgress" defaultMessage="{month} (in progress)" values={{ month: '' }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||
<FormattedMessage
|
||||
id="reportInProgress"
|
||||
defaultMessage="{month} (in progress)"
|
||||
values={{ month: `${getMonthName(preview.month, intl.locale)} ${preview.year}` }}
|
||||
/>
|
||||
</Typography>
|
||||
{(preview.daysAvailable ?? 0) < (preview.daysInMonth ?? 0) && (
|
||||
<Typography variant="caption" sx={{ opacity: 0.6 }}>
|
||||
<FormattedMessage
|
||||
id="daysOfTotal"
|
||||
defaultMessage="{available} of {total} days"
|
||||
values={{ available: preview.daysAvailable ?? 0, total: preview.daysInMonth ?? 0 }}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Preview content — reuse InsightBox + SavingsCards */}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||
<FormattedMessage id="monthlyInsights" defaultMessage="Monthly Insights" />
|
||||
</Typography>
|
||||
<InsightBox
|
||||
text={preview.aiInsight || ''}
|
||||
bullets={(preview.aiInsight || '').split(/\n+/).map(l => l.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()).filter(l => l.length > 0)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, pb: 2 }}>
|
||||
<SavingsCards
|
||||
intl={intl}
|
||||
energySaved={preview.totalEnergySaved}
|
||||
savingsCHF={preview.totalSavingsCHF}
|
||||
selfSufficiency={preview.selfSufficiencyPercent}
|
||||
batteryEfficiency={preview.batteryEfficiencyPercent}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mx: 3, mb: 3 }}>
|
||||
<FormattedMessage id="monthlyAutoNote" defaultMessage="Final report will be automatically generated on the 1st of next month." />
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
|
|
@ -992,11 +908,14 @@ function MonthlySection({
|
|||
countFn={(r: MonthlyReport) => r.weekCount}
|
||||
sendEndpoint="/SendMonthlyReportEmail"
|
||||
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
||||
htmlEndpoint="/GetMonthlyReportHtml"
|
||||
htmlParamsFn={(r: MonthlyReport) => ({ year: r.year, month: r.month })}
|
||||
installationId={installationId}
|
||||
controlledIdx={selectedIdx}
|
||||
onIdxChange={onSelectedIdxChange}
|
||||
onPeriodChange={onPeriodChange}
|
||||
/>
|
||||
) : pendingMonths.length === 0 ? (
|
||||
) : !showPreview ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
<Alert severity="warning">
|
||||
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||
|
|
@ -1012,57 +931,79 @@ function MonthlySection({
|
|||
function YearlySection({
|
||||
installationId,
|
||||
reports,
|
||||
pendingYears,
|
||||
generating,
|
||||
onGenerate,
|
||||
preview,
|
||||
selectedIdx,
|
||||
onSelectedIdxChange,
|
||||
onPeriodChange
|
||||
}: {
|
||||
installationId: number;
|
||||
reports: YearlyReport[];
|
||||
pendingYears: PendingYear[];
|
||||
generating: string | null;
|
||||
onGenerate: (year: number) => void;
|
||||
preview: YearlyReport | null;
|
||||
selectedIdx: number;
|
||||
onSelectedIdxChange: (idx: number) => void;
|
||||
onPeriodChange?: (report: YearlyReport) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const hasFinalizedCurrentYear = reports.some(r => r.year === currentYear);
|
||||
const showPreview = preview && !hasFinalizedCurrentYear;
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{/* Current year preview */}
|
||||
{showPreview && (
|
||||
<Paper sx={{ mb: 3, border: '1px solid #f0ad4e', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Box sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, borderRadius: '8px 8px 0 0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
<FormattedMessage id="yearlyReportTitle" defaultMessage="Annual Performance Report" />
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ bgcolor: '#f0ad4e', color: '#000', px: 1, py: 0.3, borderRadius: 1, fontWeight: 'bold' }}>
|
||||
<FormattedMessage id="reportInProgress" defaultMessage="{month} (in progress)" values={{ month: '' }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||
<FormattedMessage
|
||||
id="reportInProgress"
|
||||
defaultMessage="{month} (in progress)"
|
||||
values={{ month: `${preview.year}` }}
|
||||
/>
|
||||
</Typography>
|
||||
{preview.monthCount < 12 && (
|
||||
<Typography variant="caption" sx={{ opacity: 0.6 }}>
|
||||
<FormattedMessage
|
||||
id="monthsOfTotal"
|
||||
defaultMessage="{available} of {total} months"
|
||||
values={{ available: preview.monthCount, total: 12 }}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||
<FormattedMessage id="yearlyInsights" defaultMessage="Annual Insights" />
|
||||
</Typography>
|
||||
<InsightBox
|
||||
text={preview.aiInsight || ''}
|
||||
bullets={(preview.aiInsight || '').split(/\n+/).map(l => l.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()).filter(l => l.length > 0)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, pb: 2 }}>
|
||||
<SavingsCards
|
||||
intl={intl}
|
||||
energySaved={preview.totalEnergySaved}
|
||||
savingsCHF={preview.totalSavingsCHF}
|
||||
selfSufficiency={preview.selfSufficiencyPercent}
|
||||
batteryEfficiency={preview.batteryEfficiencyPercent}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mx: 3, mb: 3 }}>
|
||||
<FormattedMessage id="yearlyAutoNote" defaultMessage="Final report will be automatically generated on January 2nd." />
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
|
|
@ -1076,11 +1017,14 @@ function YearlySection({
|
|||
countFn={(r: YearlyReport) => r.monthCount}
|
||||
sendEndpoint="/SendYearlyReportEmail"
|
||||
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
||||
htmlEndpoint="/GetYearlyReportHtml"
|
||||
htmlParamsFn={(r: YearlyReport) => ({ year: r.year })}
|
||||
installationId={installationId}
|
||||
controlledIdx={selectedIdx}
|
||||
onIdxChange={onSelectedIdxChange}
|
||||
onPeriodChange={onPeriodChange}
|
||||
/>
|
||||
) : pendingYears.length === 0 ? (
|
||||
) : !showPreview ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
<Alert severity="warning">
|
||||
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||
|
|
@ -1101,6 +1045,9 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
countFn,
|
||||
sendEndpoint,
|
||||
sendParamsFn,
|
||||
htmlEndpoint,
|
||||
htmlParamsFn,
|
||||
installationId,
|
||||
controlledIdx,
|
||||
onIdxChange,
|
||||
onPeriodChange
|
||||
|
|
@ -1112,6 +1059,9 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
countFn: (r: T) => number;
|
||||
sendEndpoint: string;
|
||||
sendParamsFn: (r: T) => object;
|
||||
htmlEndpoint: string;
|
||||
htmlParamsFn: (r: T) => Record<string, any>;
|
||||
installationId: number;
|
||||
controlledIdx?: number;
|
||||
onIdxChange?: (idx: number) => void;
|
||||
onPeriodChange?: (report: T) => void;
|
||||
|
|
@ -1132,6 +1082,24 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
}
|
||||
}, [reports.length]);
|
||||
|
||||
const [reportHtml, setReportHtml] = useState<string | null>(null);
|
||||
const [htmlLoading, setHtmlLoading] = useState(false);
|
||||
|
||||
const r = reports.length > 0 ? reports[selectedIdx] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!r) return;
|
||||
setHtmlLoading(true);
|
||||
setReportHtml(null);
|
||||
axiosConfig.get(htmlEndpoint, {
|
||||
params: { installationId, language: intl.locale, source: 'web', ...htmlParamsFn(r) },
|
||||
responseType: 'text'
|
||||
})
|
||||
.then(res => setReportHtml(res.data))
|
||||
.catch(() => setReportHtml(null))
|
||||
.finally(() => setHtmlLoading(false));
|
||||
}, [r?.periodStart, r?.periodEnd, intl.locale, installationId]);
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
|
|
@ -1142,20 +1110,9 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
);
|
||||
}
|
||||
|
||||
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(/\*\*/g, '').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 }
|
||||
params: { ...sendParamsFn(r!), emailAddress }
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -1180,77 +1137,14 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
</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>
|
||||
{/* Report content — rendered from backend HTML */}
|
||||
{htmlLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
) : reportHtml ? (
|
||||
<ReportHtmlFrame html={reportHtml} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@
|
|||
"reportTitle": "Wöchentlicher Leistungsbericht",
|
||||
"weeklyInsights": "Wöchentliche Einblicke",
|
||||
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
|
||||
"weeklySavings": "Ihre Einsparungen diese Woche",
|
||||
"solarEnergyUsed": "Energie gespart",
|
||||
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
|
||||
"daysOfYourUsage": "Tage Ihres Verbrauchs",
|
||||
|
|
@ -141,10 +140,8 @@
|
|||
"atCHFRate": "bei 0,39 CHF/kWh Ø",
|
||||
"solarCoverage": "Energieunabhängigkeit",
|
||||
"fromSolarSub": "aus eigenem Solar + Batterie System",
|
||||
"avgDailyConsumption": "Ø Tagesverbrauch",
|
||||
"batteryEfficiency": "Batterieeffizienz",
|
||||
"batteryEffSub": "Entladung vs. Ladung",
|
||||
"weeklySummary": "Wöchentliche Zusammenfassung",
|
||||
"metric": "Kennzahl",
|
||||
"thisWeek": "Diese Woche",
|
||||
"change": "Änderung",
|
||||
|
|
@ -154,11 +151,12 @@
|
|||
"gridExport": "Netzeinspeisung",
|
||||
"batteryInOut": "Batterie Laden / Entladen",
|
||||
"dailyBreakdown": "Tägliche Aufschlüsselung",
|
||||
"prevWeek": "(Vorwoche)",
|
||||
"sendReport": "Bericht senden",
|
||||
"generatingReport": "Wochenbericht wird erstellt...",
|
||||
"reportSentTo": "Bericht gesendet an {email}",
|
||||
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
|
||||
"refreshReport": "Bericht aktualisieren",
|
||||
"refreshing": "Aktualisierung...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
|
||||
"proceed": "Fortfahren",
|
||||
|
|
@ -177,7 +175,7 @@
|
|||
"dailySummary": "Tagesübersicht",
|
||||
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
|
||||
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
|
||||
"currentWeekHint": "Aktuelle Woche (Mo–gestern)",
|
||||
"currentWeekHint": "Letzte 7 Tage",
|
||||
"intradayChart": "Tagesverlauf Energiefluss",
|
||||
"batteryPower": "Batterieleistung",
|
||||
"batterySoCLabel": "Batterie SoC",
|
||||
|
|
@ -191,25 +189,20 @@
|
|||
"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)",
|
||||
"regenerateReport": "Neu generieren",
|
||||
"reportInProgress": "{month} (in Bearbeitung)",
|
||||
"daysOfTotal": "{available} von {total} Tagen",
|
||||
"monthsOfTotal": "{available} von {total} Monaten",
|
||||
"monthlyAutoNote": "Der endgültige Bericht wird automatisch am 1. des nächsten Monats erstellt.",
|
||||
"yearlyAutoNote": "Der endgültige Bericht wird automatisch am 2. Januar erstellt.",
|
||||
"autoSendReports": "Berichte automatisch senden:",
|
||||
"autoSendSaved": "Automatische Versandeinstellungen gespeichert.",
|
||||
"autoSendSaveFailed": "Fehler beim Speichern der automatischen Versandeinstellungen.",
|
||||
"autoSendNoEmail": "E-Mail-Adresse im Reiter Information eingeben, um den automatischen Versand zu aktivieren",
|
||||
"generatingMonthly": "Wird generiert...",
|
||||
"generatingYearly": "Wird generiert...",
|
||||
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
||||
"recentWeeklyReports": "Letzte Wochenberichte",
|
||||
"ai_analyzing": "KI analysiert...",
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@
|
|||
"reportTitle": "Weekly Performance Report",
|
||||
"weeklyInsights": "Weekly Insights",
|
||||
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
|
||||
"weeklySavings": "Your Savings This Week",
|
||||
"solarEnergyUsed": "Energy Saved",
|
||||
"solarStayedHome": "solar + battery, not bought from grid",
|
||||
"daysOfYourUsage": "days of your usage",
|
||||
|
|
@ -123,10 +122,8 @@
|
|||
"atCHFRate": "at 0.39 CHF/kWh avg.",
|
||||
"solarCoverage": "Energy Independence",
|
||||
"fromSolarSub": "from your own solar + battery system",
|
||||
"avgDailyConsumption": "Avg Daily Consumption",
|
||||
"batteryEfficiency": "Battery Efficiency",
|
||||
"batteryEffSub": "discharge vs charge",
|
||||
"weeklySummary": "Weekly Summary",
|
||||
"metric": "Metric",
|
||||
"thisWeek": "This Week",
|
||||
"change": "Change",
|
||||
|
|
@ -136,11 +133,12 @@
|
|||
"gridExport": "Grid Export",
|
||||
"batteryInOut": "Battery Charge / Discharge",
|
||||
"dailyBreakdown": "Daily Breakdown",
|
||||
"prevWeek": "(prev week)",
|
||||
"sendReport": "Send Report",
|
||||
"generatingReport": "Generating weekly report...",
|
||||
"reportSentTo": "Report sent to {email}",
|
||||
"reportSendError": "Failed to send. Please check the email address and try again.",
|
||||
"refreshReport": "Refresh Report",
|
||||
"refreshing": "Refreshing...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Granted access to user {name}",
|
||||
"proceed": "Proceed",
|
||||
|
|
@ -159,7 +157,7 @@
|
|||
"dailySummary": "Daily Summary",
|
||||
"noDataForDate": "No data available for the selected date.",
|
||||
"noHourlyData": "Hourly data not available for this day.",
|
||||
"currentWeekHint": "Current week (Mon–yesterday)",
|
||||
"currentWeekHint": "Last 7 days",
|
||||
"intradayChart": "Intraday Power Flow",
|
||||
"batteryPower": "Battery Power",
|
||||
"batterySoCLabel": "Battery SoC",
|
||||
|
|
@ -173,25 +171,20 @@
|
|||
"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)",
|
||||
"regenerateReport": "Regenerate",
|
||||
"reportInProgress": "{month} (in progress)",
|
||||
"daysOfTotal": "{available} of {total} days",
|
||||
"monthsOfTotal": "{available} of {total} months",
|
||||
"monthlyAutoNote": "Final report will be automatically generated on the 1st of next month.",
|
||||
"yearlyAutoNote": "Final report will be automatically generated on January 2nd.",
|
||||
"autoSendReports": "Auto-send reports:",
|
||||
"autoSendSaved": "Auto-send preferences saved.",
|
||||
"autoSendSaveFailed": "Failed to save auto-send preferences.",
|
||||
"autoSendNoEmail": "Set email address in Information tab to enable auto-send",
|
||||
"generatingMonthly": "Generating...",
|
||||
"generatingYearly": "Generating...",
|
||||
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
||||
"recentWeeklyReports": "Recent Weekly Reports",
|
||||
"ai_analyzing": "AI is analyzing...",
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@
|
|||
"reportTitle": "Rapport de performance hebdomadaire",
|
||||
"weeklyInsights": "Aperçus hebdomadaires",
|
||||
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
|
||||
"weeklySavings": "Vos économies cette semaine",
|
||||
"solarEnergyUsed": "Énergie économisée",
|
||||
"solarStayedHome": "solaire + batterie, non achetée au réseau",
|
||||
"daysOfYourUsage": "jours de votre consommation",
|
||||
|
|
@ -135,10 +134,8 @@
|
|||
"atCHFRate": "à 0,39 CHF/kWh moy.",
|
||||
"solarCoverage": "Indépendance énergétique",
|
||||
"fromSolarSub": "de votre système solaire + batterie",
|
||||
"avgDailyConsumption": "Conso. quotidienne moy.",
|
||||
"batteryEfficiency": "Efficacité de la batterie",
|
||||
"batteryEffSub": "décharge vs charge",
|
||||
"weeklySummary": "Résumé hebdomadaire",
|
||||
"metric": "Métrique",
|
||||
"thisWeek": "Cette semaine",
|
||||
"change": "Variation",
|
||||
|
|
@ -148,11 +145,12 @@
|
|||
"gridExport": "Exportation réseau",
|
||||
"batteryInOut": "Batterie Charge / Décharge",
|
||||
"dailyBreakdown": "Répartition quotidienne",
|
||||
"prevWeek": "(semaine précédente)",
|
||||
"sendReport": "Envoyer le rapport",
|
||||
"generatingReport": "Génération du rapport hebdomadaire...",
|
||||
"reportSentTo": "Rapport envoyé à {email}",
|
||||
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
|
||||
"refreshReport": "Actualiser le rapport",
|
||||
"refreshing": "Actualisation...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
|
||||
"proceed": "Continuer",
|
||||
|
|
@ -171,7 +169,7 @@
|
|||
"dailySummary": "Résumé du jour",
|
||||
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
|
||||
"noHourlyData": "Données horaires non disponibles pour ce jour.",
|
||||
"currentWeekHint": "Semaine en cours (lun–hier)",
|
||||
"currentWeekHint": "7 derniers jours",
|
||||
"intradayChart": "Flux d'énergie journalier",
|
||||
"batteryPower": "Puissance batterie",
|
||||
"batterySoCLabel": "SoC batterie",
|
||||
|
|
@ -185,25 +183,20 @@
|
|||
"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)",
|
||||
"regenerateReport": "Régénérer",
|
||||
"reportInProgress": "{month} (en cours)",
|
||||
"daysOfTotal": "{available} sur {total} jours",
|
||||
"monthsOfTotal": "{available} sur {total} mois",
|
||||
"monthlyAutoNote": "Le rapport final sera généré automatiquement le 1er du mois prochain.",
|
||||
"yearlyAutoNote": "Le rapport final sera généré automatiquement le 2 janvier.",
|
||||
"autoSendReports": "Envoi automatique des rapports :",
|
||||
"autoSendSaved": "Préférences d'envoi automatique enregistrées.",
|
||||
"autoSendSaveFailed": "Échec de l'enregistrement des préférences d'envoi automatique.",
|
||||
"autoSendNoEmail": "Définir l'adresse e-mail dans l'onglet Information pour activer l'envoi automatique",
|
||||
"generatingMonthly": "Génération en cours...",
|
||||
"generatingYearly": "Génération en cours...",
|
||||
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
||||
"recentWeeklyReports": "Derniers rapports hebdomadaires",
|
||||
"ai_analyzing": "L'IA analyse...",
|
||||
|
|
|
|||
|
|
@ -138,7 +138,6 @@
|
|||
"reportTitle": "Rapporto settimanale sulle prestazioni",
|
||||
"weeklyInsights": "Approfondimenti settimanali",
|
||||
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
|
||||
"weeklySavings": "I tuoi risparmi questa settimana",
|
||||
"solarEnergyUsed": "Energia risparmiata",
|
||||
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
|
||||
"daysOfYourUsage": "giorni del tuo consumo",
|
||||
|
|
@ -146,10 +145,8 @@
|
|||
"atCHFRate": "a 0,39 CHF/kWh media",
|
||||
"solarCoverage": "Indipendenza energetica",
|
||||
"fromSolarSub": "dal proprio impianto solare + batteria",
|
||||
"avgDailyConsumption": "Consumo medio giornaliero",
|
||||
"batteryEfficiency": "Efficienza della batteria",
|
||||
"batteryEffSub": "scarica vs carica",
|
||||
"weeklySummary": "Riepilogo settimanale",
|
||||
"metric": "Metrica",
|
||||
"thisWeek": "Questa settimana",
|
||||
"change": "Variazione",
|
||||
|
|
@ -159,11 +156,12 @@
|
|||
"gridExport": "Esportazione rete",
|
||||
"batteryInOut": "Batteria Carica / Scarica",
|
||||
"dailyBreakdown": "Ripartizione giornaliera",
|
||||
"prevWeek": "(settimana precedente)",
|
||||
"sendReport": "Invia rapporto",
|
||||
"generatingReport": "Generazione del rapporto settimanale...",
|
||||
"reportSentTo": "Rapporto inviato a {email}",
|
||||
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
|
||||
"refreshReport": "Aggiorna rapporto",
|
||||
"refreshing": "Aggiornamento...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Accesso concesso all'utente {name}",
|
||||
"proceed": "Procedi",
|
||||
|
|
@ -182,7 +180,7 @@
|
|||
"dailySummary": "Riepilogo del giorno",
|
||||
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
|
||||
"noHourlyData": "Dati orari non disponibili per questo giorno.",
|
||||
"currentWeekHint": "Settimana corrente (lun–ieri)",
|
||||
"currentWeekHint": "Ultimi 7 giorni",
|
||||
"intradayChart": "Flusso energetico giornaliero",
|
||||
"batteryPower": "Potenza batteria",
|
||||
"batterySoCLabel": "SoC batteria",
|
||||
|
|
@ -196,25 +194,20 @@
|
|||
"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)",
|
||||
"regenerateReport": "Rigenera",
|
||||
"reportInProgress": "{month} (in corso)",
|
||||
"daysOfTotal": "{available} di {total} giorni",
|
||||
"monthsOfTotal": "{available} di {total} mesi",
|
||||
"monthlyAutoNote": "Il rapporto finale verrà generato automaticamente il 1° del mese prossimo.",
|
||||
"yearlyAutoNote": "Il rapporto finale verrà generato automaticamente il 2 gennaio.",
|
||||
"autoSendReports": "Invio automatico rapporti:",
|
||||
"autoSendSaved": "Preferenze di invio automatico salvate.",
|
||||
"autoSendSaveFailed": "Impossibile salvare le preferenze di invio automatico.",
|
||||
"autoSendNoEmail": "Impostare l'indirizzo e-mail nella scheda Informazioni per attivare l'invio automatico",
|
||||
"generatingMonthly": "Generazione in corso...",
|
||||
"generatingYearly": "Generazione in corso...",
|
||||
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
||||
"recentWeeklyReports": "Ultimi rapporti settimanali",
|
||||
"ai_analyzing": "L'IA sta analizzando...",
|
||||
|
|
|
|||
Loading…
Reference in New Issue