AI-P1: Weekly Performance Report 1.0
This commit is contained in:
parent
c076d55407
commit
77f6e0de6c
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AWSSDK.S3" Version="3.7.205.17" />
|
<PackageReference Include="AWSSDK.S3" Version="3.7.205.17" />
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
|
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
|
||||||
<PackageReference Include="Microsoft.AspNet.Identity.Core" Version="2.2.3" />
|
<PackageReference Include="Microsoft.AspNet.Identity.Core" Version="2.2.3" />
|
||||||
|
|
|
||||||
|
|
@ -862,6 +862,66 @@ public class Controller : ControllerBase
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Weekly Performance Report ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a weekly performance report from an Excel file in tmp_report/{installationId}.xlsx
|
||||||
|
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet(nameof(GetWeeklyReport))]
|
||||||
|
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user == null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null || !user.HasAccessTo(installation))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var filePath = Environment.CurrentDirectory + "/tmp_report/" + installationId + ".xlsx";
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
return NotFound($"No report data file found. Please place the Excel export at: tmp_report/{installationId}.xlsx");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName);
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[GetWeeklyReport] Error: {ex.Message}");
|
||||||
|
return BadRequest($"Failed to generate report: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the weekly report as a formatted HTML email.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost(nameof(SendWeeklyReportEmail))]
|
||||||
|
public async Task<ActionResult> SendWeeklyReportEmail(Int64 installationId, string emailAddress, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user == null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null || !user.HasAccessTo(installation))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName);
|
||||||
|
await ReportEmailService.SendReportEmailAsync(report, emailAddress);
|
||||||
|
return Ok(new { message = $"Report sent to {emailAddress}" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SendWeeklyReportEmail] Error: {ex.Message}");
|
||||||
|
return BadRequest($"Failed to send report: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut(nameof(UpdateFolder))]
|
[HttpPut(nameof(UpdateFolder))]
|
||||||
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public class DailyEnergyData
|
||||||
|
{
|
||||||
|
public string Date { get; set; } = ""; // "2026-02-10"
|
||||||
|
public double PvProduction { get; set; } // kWh
|
||||||
|
public double LoadConsumption { get; set; } // kWh
|
||||||
|
public double GridImport { get; set; } // kWh
|
||||||
|
public double GridExport { get; set; } // kWh
|
||||||
|
public double BatteryCharged { get; set; } // kWh
|
||||||
|
public double BatteryDischarged { get; set; } // kWh
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public class WeeklyReportResponse
|
||||||
|
{
|
||||||
|
public string InstallationName { get; set; } = "";
|
||||||
|
public string PeriodStart { get; set; } = ""; // current week start
|
||||||
|
public string PeriodEnd { get; set; } = ""; // current week end
|
||||||
|
|
||||||
|
public WeeklySummary CurrentWeek { get; set; } = new();
|
||||||
|
public WeeklySummary? PreviousWeek { get; set; }
|
||||||
|
|
||||||
|
// Key ratios (current week)
|
||||||
|
public double SelfSufficiencyPercent { get; set; }
|
||||||
|
public double SelfConsumptionPercent { get; set; }
|
||||||
|
public double BatteryEfficiencyPercent { get; set; }
|
||||||
|
public double GridDependencyPercent { get; set; }
|
||||||
|
|
||||||
|
// Week-over-week change percentages
|
||||||
|
public double PvChangePercent { get; set; }
|
||||||
|
public double ConsumptionChangePercent { get; set; }
|
||||||
|
public double GridImportChangePercent { get; set; }
|
||||||
|
|
||||||
|
public List<DailyEnergyData> DailyData { get; set; } = new();
|
||||||
|
public string AiInsight { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WeeklySummary
|
||||||
|
{
|
||||||
|
public double TotalPvProduction { get; set; }
|
||||||
|
public double TotalConsumption { get; set; }
|
||||||
|
public double TotalGridImport { get; set; }
|
||||||
|
public double TotalGridExport { get; set; }
|
||||||
|
public double TotalBatteryCharged { get; set; }
|
||||||
|
public double TotalBatteryDischarged { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"SmtpServerUrl" : "smtp.gmail.com",
|
"SmtpServerUrl" : "mail.agenturserver.de",
|
||||||
"SmtpUsername" : "angelis@inesco.energy",
|
"SmtpUsername" : "no-reply@inesco.ch",
|
||||||
"SmtpPassword" : "huvu pkqd kakz hqtm ",
|
"SmtpPassword" : "1ci4vi%+bfccIp",
|
||||||
"SmtpPort" : 587,
|
"SmtpPort" : 587,
|
||||||
"SenderName" : "Inesco Energy",
|
"SenderName" : "Inesco Energy",
|
||||||
"SenderAddress" : "noreply@inesco.energy"
|
"SenderAddress" : "no-reply@inesco.ch"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
public static class ExcelDataParser
|
||||||
|
{
|
||||||
|
// Column headers from the ESS Link Cloud Excel export
|
||||||
|
private const string ColDateTime = "Data time";
|
||||||
|
private const string ColPvToday = "PV Generated Energy Today";
|
||||||
|
private const string ColLoadToday = "Load Consumption Today";
|
||||||
|
private const string ColGridImportToday = "Purchased Energy Today";
|
||||||
|
private const string ColGridExportToday = "Feed in energy Today";
|
||||||
|
private const string ColBattChargedToday = "Daily Battery Charged";
|
||||||
|
private const string ColBattDischargedToday = "Battery Discharged Today";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an ESS Link Cloud Excel export file and returns one DailyEnergyData per day.
|
||||||
|
/// Takes the last row of each day (where "Today" cumulative values are highest).
|
||||||
|
/// </summary>
|
||||||
|
public static List<DailyEnergyData> Parse(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
throw new FileNotFoundException($"Excel file not found: {filePath}");
|
||||||
|
|
||||||
|
using var workbook = new XLWorkbook(filePath);
|
||||||
|
var worksheet = workbook.Worksheet(1);
|
||||||
|
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0;
|
||||||
|
|
||||||
|
if (lastRow < 2)
|
||||||
|
throw new InvalidOperationException("Excel file has no data rows.");
|
||||||
|
|
||||||
|
// Find column indices by header name (row 1)
|
||||||
|
var headerRow = worksheet.Row(1);
|
||||||
|
var colMap = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++)
|
||||||
|
{
|
||||||
|
var header = headerRow.Cell(col).GetString().Trim();
|
||||||
|
if (!string.IsNullOrEmpty(header))
|
||||||
|
colMap[header] = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required columns exist
|
||||||
|
var requiredCols = new[] { ColDateTime, ColPvToday, ColLoadToday, ColGridImportToday, ColGridExportToday, ColBattChargedToday, ColBattDischargedToday };
|
||||||
|
foreach (var rc in requiredCols)
|
||||||
|
{
|
||||||
|
if (!colMap.ContainsKey(rc))
|
||||||
|
throw new InvalidOperationException($"Required column '{rc}' not found in Excel file. Available: {string.Join(", ", colMap.Keys)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all rows, group by date, keep last row per day
|
||||||
|
var dailyLastRows = new SortedDictionary<string, DailyEnergyData>();
|
||||||
|
|
||||||
|
for (var row = 2; row <= lastRow; row++)
|
||||||
|
{
|
||||||
|
var dateTimeStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim();
|
||||||
|
if (string.IsNullOrEmpty(dateTimeStr)) continue;
|
||||||
|
|
||||||
|
// Extract date portion (first 10 chars: "2026-02-10")
|
||||||
|
var date = dateTimeStr.Length >= 10 ? dateTimeStr[..10] : dateTimeStr;
|
||||||
|
|
||||||
|
var data = new DailyEnergyData
|
||||||
|
{
|
||||||
|
Date = date,
|
||||||
|
PvProduction = GetDouble(worksheet, row, colMap[ColPvToday]),
|
||||||
|
LoadConsumption = GetDouble(worksheet, row, colMap[ColLoadToday]),
|
||||||
|
GridImport = GetDouble(worksheet, row, colMap[ColGridImportToday]),
|
||||||
|
GridExport = GetDouble(worksheet, row, colMap[ColGridExportToday]),
|
||||||
|
BatteryCharged = GetDouble(worksheet, row, colMap[ColBattChargedToday]),
|
||||||
|
BatteryDischarged = GetDouble(worksheet, row, colMap[ColBattDischargedToday]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always overwrite — last row of the day has the final cumulative values
|
||||||
|
dailyLastRows[date] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[ExcelDataParser] Parsed {dailyLastRows.Count} days from {filePath}");
|
||||||
|
return dailyLastRows.Values.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double GetDouble(IXLWorksheet ws, int row, int col)
|
||||||
|
{
|
||||||
|
var cell = ws.Row(row).Cell(col);
|
||||||
|
if (cell.IsEmpty()) return 0;
|
||||||
|
return cell.TryGetValue<double>(out var val) ? Math.Round(val, 4) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
using InnovEnergy.Lib.Mailer;
|
||||||
|
using MailKit.Net.Smtp;
|
||||||
|
using MailKit.Security;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
public static class ReportEmailService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the weekly report as a nicely formatted HTML email.
|
||||||
|
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail)
|
||||||
|
{
|
||||||
|
var subject = $"Weekly Energy Report — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
|
||||||
|
var html = BuildHtmlEmail(report);
|
||||||
|
|
||||||
|
var config = await ReadMailerConfig();
|
||||||
|
|
||||||
|
var from = new MailboxAddress(config.SenderName, config.SenderAddress);
|
||||||
|
var to = new MailboxAddress(recipientEmail, recipientEmail);
|
||||||
|
|
||||||
|
var msg = new MimeMessage
|
||||||
|
{
|
||||||
|
From = { from },
|
||||||
|
To = { to },
|
||||||
|
Subject = subject,
|
||||||
|
Body = new TextPart("html") { Text = html }
|
||||||
|
};
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportEmailService] SMTP: {config.SmtpUsername}@{config.SmtpServerUrl}:{config.SmtpPort}");
|
||||||
|
|
||||||
|
using var smtp = new SmtpClient();
|
||||||
|
await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls);
|
||||||
|
await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword);
|
||||||
|
await smtp.SendAsync(msg);
|
||||||
|
await smtp.DisconnectAsync(true);
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<MailerConfig> ReadMailerConfig()
|
||||||
|
{
|
||||||
|
await using var fileStream = File.OpenRead(MailerConfig.DefaultFile);
|
||||||
|
var config = await JsonSerializer.DeserializeAsync<MailerConfig>(fileStream);
|
||||||
|
return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML email template ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static string BuildHtmlEmail(WeeklyReportResponse r)
|
||||||
|
{
|
||||||
|
var cur = r.CurrentWeek;
|
||||||
|
var prev = r.PreviousWeek;
|
||||||
|
|
||||||
|
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ")
|
||||||
|
var insightLines = r.AiInsight
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", ""))
|
||||||
|
.Where(l => l.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var insightHtml = insightLines.Count > 1
|
||||||
|
? "<ul style=\"margin:0;padding-left:20px\">" +
|
||||||
|
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
|
||||||
|
"</ul>"
|
||||||
|
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(r.AiInsight)}</p>";
|
||||||
|
|
||||||
|
// Daily rows
|
||||||
|
var dailyRows = "";
|
||||||
|
foreach (var d in r.DailyData)
|
||||||
|
{
|
||||||
|
var dayName = DateTime.Parse(d.Date).ToString("ddd");
|
||||||
|
var isCurrentWeek = string.Compare(d.Date, r.PeriodStart, StringComparison.Ordinal) >= 0;
|
||||||
|
var bgColor = isCurrentWeek ? "#ffffff" : "#f9f9f9";
|
||||||
|
dailyRows += $@"
|
||||||
|
<tr style=""background:{bgColor}"">
|
||||||
|
<td style=""padding:6px 10px;border-bottom:1px solid #eee"">{dayName} {d.Date}</td>
|
||||||
|
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.PvProduction:F1}</td>
|
||||||
|
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.LoadConsumption:F1}</td>
|
||||||
|
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.GridImport:F1}</td>
|
||||||
|
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.GridExport:F1}</td>
|
||||||
|
<td style=""padding:6px 10px;border-bottom:1px solid #eee;text-align:right"">{d.BatteryCharged:F1}/{d.BatteryDischarged:F1}</td>
|
||||||
|
</tr>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Week-over-week comparison rows
|
||||||
|
var comparisonHtml = prev != null
|
||||||
|
? $@"
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">PV Production</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalPvProduction:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(r.PvChangePercent)}"">{FormatChange(r.PvChangePercent)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">Consumption</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalConsumption:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.ConsumptionChangePercent)}"">{FormatChange(r.ConsumptionChangePercent)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">Grid Import</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridImport:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.GridImportChangePercent)}"">{FormatChange(r.GridImportChangePercent)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">Grid Export</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridExport:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right"">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">Battery In/Out</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh</td>
|
||||||
|
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right"">—</td>
|
||||||
|
</tr>"
|
||||||
|
: $@"
|
||||||
|
<tr><td style=""padding:8px 12px"">PV Production</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px"">Consumption</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px"">Grid Import</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px"">Grid Export</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td></tr>
|
||||||
|
<tr><td style=""padding:8px 12px"">Battery In/Out</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td></tr>";
|
||||||
|
|
||||||
|
var comparisonHeaders = prev != null
|
||||||
|
? @"<th style=""padding:8px 12px;text-align:right"">This Week</th>
|
||||||
|
<th style=""padding:8px 12px;text-align:right"">Last Week</th>
|
||||||
|
<th style=""padding:8px 12px;text-align:right"">Change</th>"
|
||||||
|
: @"<th style=""padding:8px 12px;text-align:right"">This Week</th>";
|
||||||
|
|
||||||
|
return $@"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset=""utf-8""></head>
|
||||||
|
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
|
||||||
|
<tr><td align=""center"">
|
||||||
|
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
|
||||||
|
<div style=""font-size:20px;font-weight:bold"">Weekly Performance Report</div>
|
||||||
|
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{r.InstallationName}</div>
|
||||||
|
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{r.PeriodStart} — {r.PeriodEnd}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Weekly Insights (top) -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:24px 30px 0"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">Weekly Insights</div>
|
||||||
|
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
|
||||||
|
{insightHtml}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Weekly Totals -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:24px 30px"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">Weekly Summary</div>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
|
||||||
|
<tr style=""background:#f8f9fa"">
|
||||||
|
<th style=""padding:8px 12px;text-align:left"">Metric</th>
|
||||||
|
{comparisonHeaders}
|
||||||
|
</tr>
|
||||||
|
{comparisonHtml}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Key Ratios -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:0 30px 24px"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">Your Savings This Week</div>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
||||||
|
<tr>
|
||||||
|
{SavingsBox("Solar Energy Used", $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", "stayed at home", "#27ae60")}
|
||||||
|
{SavingsBox("Est. Money Saved", $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", "at 0.27 CHF/kWh", "#2980b9")}
|
||||||
|
{SavingsBox("Solar Coverage", $"{r.SelfSufficiencyPercent:F0}%", "from solar", "#8e44ad")}
|
||||||
|
{SavingsBox("Battery Eff.", $"{r.BatteryEfficiencyPercent:F0}%", "out vs in", "#e67e22")}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Daily Breakdown -->
|
||||||
|
<tr>
|
||||||
|
<td style=""padding:0 30px 24px"">
|
||||||
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">Daily Breakdown (kWh)</div>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px;font-size:13px"">
|
||||||
|
<tr style=""background:#f8f9fa"">
|
||||||
|
<th style=""padding:6px 10px;text-align:left"">Day</th>
|
||||||
|
<th style=""padding:6px 10px;text-align:right"">PV</th>
|
||||||
|
<th style=""padding:6px 10px;text-align:right"">Load</th>
|
||||||
|
<th style=""padding:6px 10px;text-align:right"">Grid In</th>
|
||||||
|
<th style=""padding:6px 10px;text-align:right"">Grid Out</th>
|
||||||
|
<th style=""padding:6px 10px;text-align:right"">Batt In/Out</th>
|
||||||
|
</tr>
|
||||||
|
{dailyRows}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||||
|
Generated by <strong style=""color:#666"">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SavingsBox(string label, string value, string subtitle, string color) =>
|
||||||
|
$@"<td width=""25%"" style=""text-align:center"">
|
||||||
|
<div style=""background:#f8f9fa;border-radius:6px;padding:12px 4px"">
|
||||||
|
<div style=""font-size:22px;font-weight:bold;color:{color}"">{value}</div>
|
||||||
|
<div style=""font-size:12px;font-weight:bold;color:#444;margin-top:4px"">{label}</div>
|
||||||
|
<div style=""font-size:10px;color:#888;margin-top:2px"">{subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</td>";
|
||||||
|
|
||||||
|
// Bolds "Title" before first colon, and numbers+units in the rest
|
||||||
|
private static string FormatInsightLine(string line)
|
||||||
|
{
|
||||||
|
var colonIdx = line.IndexOf(':');
|
||||||
|
string result;
|
||||||
|
if (colonIdx > 0)
|
||||||
|
{
|
||||||
|
var title = line[..colonIdx];
|
||||||
|
var rest = line[colonIdx..]; // includes the colon
|
||||||
|
result = $"<strong>{title}</strong>{rest}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = line;
|
||||||
|
}
|
||||||
|
// Bold numbers followed by units
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
result,
|
||||||
|
@"(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))",
|
||||||
|
"<strong>$1</strong>");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatChange(double pct) =>
|
||||||
|
pct == 0 ? "—" : pct > 0 ? $"+{pct:F1}%" : $"{pct:F1}%";
|
||||||
|
|
||||||
|
private static string ChangeColor(double pct) =>
|
||||||
|
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Flurl.Http;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
public static class WeeklyReportService
|
||||||
|
{
|
||||||
|
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<string, string> InsightCache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a full weekly report for the given installation.
|
||||||
|
/// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName)
|
||||||
|
{
|
||||||
|
var xlsxPath = TmpReportDir + installationId + ".xlsx";
|
||||||
|
var cachePath = TmpReportDir + installationId + ".cache.json";
|
||||||
|
|
||||||
|
// Use cached report if xlsx hasn't changed since cache was written
|
||||||
|
if (File.Exists(cachePath) && File.Exists(xlsxPath))
|
||||||
|
{
|
||||||
|
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
|
||||||
|
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
|
||||||
|
if (cacheModified > xlsxModified)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
|
||||||
|
await File.ReadAllTextAsync(cachePath));
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId}.");
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Cache corrupt — regenerate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allDays = ExcelDataParser.Parse(xlsxPath);
|
||||||
|
var report = await GenerateReportFromDataAsync(allDays, installationName);
|
||||||
|
|
||||||
|
// Write cache
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core report generation from daily data. Data-source agnostic.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
||||||
|
List<DailyEnergyData> allDays, string installationName)
|
||||||
|
{
|
||||||
|
// Sort by date
|
||||||
|
allDays = allDays.OrderBy(d => d.Date).ToList();
|
||||||
|
|
||||||
|
// Split into previous week and current week
|
||||||
|
List<DailyEnergyData> previousWeekDays;
|
||||||
|
List<DailyEnergyData> currentWeekDays;
|
||||||
|
|
||||||
|
if (allDays.Count > 7)
|
||||||
|
{
|
||||||
|
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
|
||||||
|
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
previousWeekDays = new List<DailyEnergyData>();
|
||||||
|
currentWeekDays = allDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSummary = Summarize(currentWeekDays);
|
||||||
|
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
||||||
|
|
||||||
|
// Calculate key ratios for current week
|
||||||
|
var selfSufficiency = currentSummary.TotalConsumption > 0
|
||||||
|
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var selfConsumption = currentSummary.TotalPvProduction > 0
|
||||||
|
? Math.Round((currentSummary.TotalPvProduction - currentSummary.TotalGridExport) / currentSummary.TotalPvProduction * 100, 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var batteryEfficiency = currentSummary.TotalBatteryCharged > 0
|
||||||
|
? Math.Round(currentSummary.TotalBatteryDischarged / currentSummary.TotalBatteryCharged * 100, 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var gridDependency = currentSummary.TotalConsumption > 0
|
||||||
|
? Math.Round(currentSummary.TotalGridImport / currentSummary.TotalConsumption * 100, 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Week-over-week changes
|
||||||
|
var pvChange = PercentChange(previousSummary?.TotalPvProduction, currentSummary.TotalPvProduction);
|
||||||
|
var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption);
|
||||||
|
var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport);
|
||||||
|
|
||||||
|
// AI insight
|
||||||
|
var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary,
|
||||||
|
selfSufficiency, gridDependency, batteryEfficiency, installationName);
|
||||||
|
|
||||||
|
return new WeeklyReportResponse
|
||||||
|
{
|
||||||
|
InstallationName = installationName,
|
||||||
|
PeriodStart = currentWeekDays.First().Date,
|
||||||
|
PeriodEnd = currentWeekDays.Last().Date,
|
||||||
|
CurrentWeek = currentSummary,
|
||||||
|
PreviousWeek = previousSummary,
|
||||||
|
SelfSufficiencyPercent = selfSufficiency,
|
||||||
|
SelfConsumptionPercent = selfConsumption,
|
||||||
|
BatteryEfficiencyPercent = batteryEfficiency,
|
||||||
|
GridDependencyPercent = gridDependency,
|
||||||
|
PvChangePercent = pvChange,
|
||||||
|
ConsumptionChangePercent = consumptionChange,
|
||||||
|
GridImportChangePercent = gridImportChange,
|
||||||
|
DailyData = allDays,
|
||||||
|
AiInsight = aiInsight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WeeklySummary Summarize(List<DailyEnergyData> days) => new()
|
||||||
|
{
|
||||||
|
TotalPvProduction = Math.Round(days.Sum(d => d.PvProduction), 1),
|
||||||
|
TotalConsumption = Math.Round(days.Sum(d => d.LoadConsumption), 1),
|
||||||
|
TotalGridImport = Math.Round(days.Sum(d => d.GridImport), 1),
|
||||||
|
TotalGridExport = Math.Round(days.Sum(d => d.GridExport), 1),
|
||||||
|
TotalBatteryCharged = Math.Round(days.Sum(d => d.BatteryCharged), 1),
|
||||||
|
TotalBatteryDischarged = Math.Round(days.Sum(d => d.BatteryDischarged), 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static double PercentChange(double? previous, double current)
|
||||||
|
{
|
||||||
|
if (previous is null or 0) return 0;
|
||||||
|
return Math.Round((current - previous.Value) / previous.Value * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mistral AI Insight ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||||
|
|
||||||
|
private static async Task<string> GetAiInsightAsync(
|
||||||
|
List<DailyEnergyData> currentWeek,
|
||||||
|
WeeklySummary current,
|
||||||
|
WeeklySummary? previous,
|
||||||
|
double selfSufficiency,
|
||||||
|
double gridDependency,
|
||||||
|
double batteryEfficiency,
|
||||||
|
string installationName)
|
||||||
|
{
|
||||||
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[WeeklyReportService] MISTRAL_API_KEY not set — skipping AI insight.");
|
||||||
|
return "AI insight unavailable (API key not configured).";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache key: installation + period
|
||||||
|
var cacheKey = $"{installationName}_{currentWeek.Last().Date}";
|
||||||
|
if (InsightCache.TryGetValue(cacheKey, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
// Build compact prompt
|
||||||
|
var dayLines = string.Join("\n", currentWeek.Select(d =>
|
||||||
|
{
|
||||||
|
var dayName = DateTime.Parse(d.Date).ToString("ddd");
|
||||||
|
return $"{dayName} {d.Date}: PV={d.PvProduction:F1} Load={d.LoadConsumption:F1} GridIn={d.GridImport:F1} GridOut={d.GridExport:F1} BattIn={d.BatteryCharged:F1} BattOut={d.BatteryDischarged:F1}";
|
||||||
|
}));
|
||||||
|
|
||||||
|
var comparison = previous != null
|
||||||
|
? $"vs Last week: PV {current.TotalPvProduction} vs {previous.TotalPvProduction}, Grid Import {current.TotalGridImport} vs {previous.TotalGridImport}, Consumption {current.TotalConsumption} vs {previous.TotalConsumption}"
|
||||||
|
: "No previous week data available.";
|
||||||
|
|
||||||
|
var solarSavings = Math.Round(current.TotalPvProduction - current.TotalGridExport, 1);
|
||||||
|
|
||||||
|
var prompt = $@"You are an energy advisor for a SodistoreHome installation: ""{installationName}"".
|
||||||
|
|
||||||
|
Write exactly 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||||
|
|
||||||
|
1. Solar savings: this week the system saved {solarSavings} kWh from the grid. Explain what this means in simple terms (e.g. equivalent to X days of average household use, or roughly X CHF saved at ~0.27 CHF/kWh).
|
||||||
|
2. Best vs worst solar day: name the best and worst days with their PV kWh values. Mention likely weather reason.
|
||||||
|
3. Battery performance: was the battery well-utilized this week? Mention charge/discharge totals and any standout days.
|
||||||
|
4. Tip of the week: one specific, practical recommendation based on THIS week's patterns to save more energy or money.
|
||||||
|
|
||||||
|
Rules: Use actual day names and numbers. Keep each bullet to 1-2 sentences. Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks.
|
||||||
|
|
||||||
|
Daily data (kWh):
|
||||||
|
{dayLines}
|
||||||
|
|
||||||
|
Totals: PV={current.TotalPvProduction:F1} Load={current.TotalConsumption:F1} GridIn={current.TotalGridImport:F1} GridOut={current.TotalGridExport:F1} BattIn={current.TotalBatteryCharged:F1} BattOut={current.TotalBatteryDischarged:F1}
|
||||||
|
Solar used at home={solarSavings} kWh ({selfSufficiency}% of consumption covered by solar)
|
||||||
|
Battery-eff={batteryEfficiency}%
|
||||||
|
{comparison}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
model = "mistral-small-latest",
|
||||||
|
messages = new[] { new { role = "user", content = prompt } },
|
||||||
|
max_tokens = 350,
|
||||||
|
temperature = 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
var responseText = await MistralUrl
|
||||||
|
.WithHeader("Authorization", $"Bearer {apiKey}")
|
||||||
|
.PostJsonAsync(requestBody)
|
||||||
|
.ReceiveString();
|
||||||
|
|
||||||
|
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
|
||||||
|
var content = (string?)envelope?.choices?[0]?.message?.content;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
var insight = content.Trim();
|
||||||
|
InsightCache.TryAdd(cacheKey, insight);
|
||||||
|
Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars).");
|
||||||
|
return insight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[WeeklyReportService] Mistral error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "AI insight could not be generated at this time.";
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -20,5 +20,6 @@
|
||||||
"configuration": "configuration",
|
"configuration": "configuration",
|
||||||
"history": "history",
|
"history": "history",
|
||||||
"mainstats": "mainstats",
|
"mainstats": "mainstats",
|
||||||
"detailed_view": "detailed_view/"
|
"detailed_view": "detailed_view/",
|
||||||
|
"report": "report"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
|
||||||
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
||||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||||
import Overview from '../Overview/overview';
|
import Overview from '../Overview/overview';
|
||||||
|
import WeeklyReport from './WeeklyReport';
|
||||||
|
|
||||||
interface singleInstallationProps {
|
interface singleInstallationProps {
|
||||||
current_installation?: I_Installation;
|
current_installation?: I_Installation;
|
||||||
|
|
@ -430,7 +431,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
currentTab != 'overview' &&
|
currentTab != 'overview' &&
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'log' && (
|
currentTab != 'log' &&
|
||||||
|
currentTab != 'report' && (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="xl"
|
maxWidth="xl"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -557,6 +559,17 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{currentUser.userType == UserType.admin && (
|
||||||
|
<Route
|
||||||
|
path={routes.report}
|
||||||
|
element={
|
||||||
|
<WeeklyReport
|
||||||
|
installationId={props.current_installation.id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.live}></Navigate>}
|
element={<Navigate to={routes.live}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,447 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Alert
|
||||||
|
} from '@mui/material';
|
||||||
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
|
||||||
|
interface WeeklyReportProps {
|
||||||
|
installationId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyEnergyData {
|
||||||
|
date: string;
|
||||||
|
pvProduction: number;
|
||||||
|
loadConsumption: number;
|
||||||
|
gridImport: number;
|
||||||
|
gridExport: number;
|
||||||
|
batteryCharged: number;
|
||||||
|
batteryDischarged: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklySummary {
|
||||||
|
totalPvProduction: number;
|
||||||
|
totalConsumption: number;
|
||||||
|
totalGridImport: number;
|
||||||
|
totalGridExport: number;
|
||||||
|
totalBatteryCharged: number;
|
||||||
|
totalBatteryDischarged: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyReportResponse {
|
||||||
|
installationName: string;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
currentWeek: WeeklySummary;
|
||||||
|
previousWeek: WeeklySummary | null;
|
||||||
|
selfSufficiencyPercent: number;
|
||||||
|
selfConsumptionPercent: number;
|
||||||
|
batteryEfficiencyPercent: number;
|
||||||
|
gridDependencyPercent: number;
|
||||||
|
pvChangePercent: number;
|
||||||
|
consumptionChangePercent: number;
|
||||||
|
gridImportChangePercent: number;
|
||||||
|
dailyData: DailyEnergyData[];
|
||||||
|
aiInsight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units
|
||||||
|
function FormattedBullet({ text }: { text: string }) {
|
||||||
|
const colonIdx = text.indexOf(':');
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const title = text.slice(0, colonIdx);
|
||||||
|
const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..."
|
||||||
|
// Bold numbers+units in the rest
|
||||||
|
const restParts = rest.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) =>
|
||||||
|
/\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p)
|
||||||
|
? <strong key={i}>{p}</strong>
|
||||||
|
: <span key={i}>{p}</span>
|
||||||
|
);
|
||||||
|
return <><strong>{title}</strong>:{restParts}</>;
|
||||||
|
}
|
||||||
|
// No colon — just bold numbers
|
||||||
|
const parts = text.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) =>
|
||||||
|
/\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p)
|
||||||
|
? <strong key={i}>{p}</strong>
|
||||||
|
: <span key={i}>{p}</span>
|
||||||
|
);
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
|
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sendStatus, setSendStatus] = useState<{
|
||||||
|
message: string;
|
||||||
|
severity: 'success' | 'error';
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport();
|
||||||
|
}, [installationId]);
|
||||||
|
|
||||||
|
const fetchReport = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await axiosConfig.get('/GetWeeklyReport', {
|
||||||
|
params: { installationId }
|
||||||
|
});
|
||||||
|
setReport(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg =
|
||||||
|
err.response?.data ||
|
||||||
|
err.message ||
|
||||||
|
'Failed to load report. Make sure the Excel file is placed in tmp_report/';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = async () => {
|
||||||
|
if (!email.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await axiosConfig.post('/SendWeeklyReportEmail', null, {
|
||||||
|
params: { installationId, emailAddress: email.trim() }
|
||||||
|
});
|
||||||
|
setSendStatus({ message: `Report sent to ${email}`, severity: 'success' });
|
||||||
|
} catch (err: any) {
|
||||||
|
setSendStatus({ message: 'Failed to send. Please check the email address and try again.', severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '50vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
|
||||||
|
<Typography variant="body2" mt={2}>
|
||||||
|
Generating weekly report...
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container sx={{ py: 4 }}>
|
||||||
|
<Alert severity="warning">{error}</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) return null;
|
||||||
|
|
||||||
|
const cur = report.currentWeek;
|
||||||
|
const prev = report.previousWeek;
|
||||||
|
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse AI insight into bullet points
|
||||||
|
const insightBullets = report.aiInsight
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
// Savings-focused KPI values
|
||||||
|
const solarSavingsKwh = Math.round((cur.totalPvProduction - cur.totalGridExport) * 10) / 10;
|
||||||
|
const estimatedSavingsCHF = Math.round(solarSavingsKwh * 0.27 * 10) / 10;
|
||||||
|
|
||||||
|
// Find max value for daily bar chart scaling
|
||||||
|
const maxDailyValue = Math.max(
|
||||||
|
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
{/* Email bar */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mb: 3, gap: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setSendStatus(null); }}
|
||||||
|
sx={{ width: 280 }}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSendEmail()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={sending ? <CircularProgress size={16} color="inherit" /> : <SendIcon />}
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
disabled={sending || !email.trim()}
|
||||||
|
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
|
||||||
|
>
|
||||||
|
Send Report
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{sendStatus && (
|
||||||
|
<Typography variant="caption" sx={{ color: sendStatus.severity === 'success' ? '#27ae60' : '#e74c3c' }}>
|
||||||
|
{sendStatus.message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Report Header */}
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#2c3e50',
|
||||||
|
color: '#fff',
|
||||||
|
p: 3,
|
||||||
|
mb: 3,
|
||||||
|
borderRadius: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Weekly Insights (was AI Insights) */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
Weekly Insights
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#fef9e7',
|
||||||
|
borderLeft: '4px solid #f39c12',
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: '0 8px 8px 0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{insightBullets.length > 1 ? (
|
||||||
|
<Box component="ul" sx={{ m: 0, pl: 2.5, '& li': { mb: 1.2, lineHeight: 1.7, fontSize: '14px', color: '#333' } }}>
|
||||||
|
{insightBullets.map((bullet, i) => (
|
||||||
|
<li key={i}><FormattedBullet text={bullet} /></li>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ lineHeight: 1.7, fontSize: '14px', color: '#333' }}>
|
||||||
|
<FormattedBullet text={report.aiInsight} />
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Your Savings This Week */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
Your Savings This Week
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label="Solar Energy Used"
|
||||||
|
value={`${solarSavingsKwh} kWh`}
|
||||||
|
subtitle="of your solar stayed at home"
|
||||||
|
color="#27ae60"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label="Est. Money Saved"
|
||||||
|
value={`~${estimatedSavingsCHF} CHF`}
|
||||||
|
subtitle="at 0.27 CHF/kWh avg."
|
||||||
|
color="#2980b9"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label="Solar Coverage"
|
||||||
|
value={`${report.selfSufficiencyPercent.toFixed(0)}%`}
|
||||||
|
subtitle="of consumption from solar"
|
||||||
|
color="#8e44ad"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<SavingsCard
|
||||||
|
label="Battery Efficiency"
|
||||||
|
value={`${report.batteryEfficiencyPercent.toFixed(0)}%`}
|
||||||
|
subtitle="energy out vs energy in"
|
||||||
|
color="#e67e22"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Weekly Summary Table */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
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' }}>Metric</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>This Week</th>
|
||||||
|
{prev && <th style={{ textAlign: 'right' }}>Last Week</th>}
|
||||||
|
{prev && <th style={{ textAlign: 'right' }}>Change</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>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>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>
|
||||||
|
<td>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>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' }}>—</td>}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>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' }}>—</td>}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Daily Breakdown - CSS bar chart */}
|
||||||
|
{report.dailyData.length > 0 && (
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
|
||||||
|
Daily Breakdown
|
||||||
|
</Typography>
|
||||||
|
{/* Legend */}
|
||||||
|
<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' }} /> PV Production
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Box sx={{ width: 12, height: 12, bgcolor: '#3498db', borderRadius: '2px' }} /> Consumption
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> Grid Import
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{/* Bars */}
|
||||||
|
{report.dailyData.map((d, i) => {
|
||||||
|
const dt = new Date(d.date);
|
||||||
|
const dayLabel = dt.toLocaleDateString('en-US', { 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 }}>(prev week)</span>}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#888', fontSize: '11px' }}>
|
||||||
|
PV {d.pvProduction.toFixed(1)} | Load {d.loadConsumption.toFixed(1)} | Grid {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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavingsCard({ label, value, subtitle, color }: { label: string; value: string; subtitle: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#f8f9fa',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" fontWeight="bold" sx={{ color, fontSize: { xs: '1.5rem', sm: '2rem' } }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight="bold" sx={{ color: '#444', mt: 0.5 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WeeklyReport;
|
||||||
|
|
@ -30,7 +30,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
'manage',
|
'manage',
|
||||||
'log',
|
'log',
|
||||||
'history',
|
'history',
|
||||||
'configuration'
|
'configuration',
|
||||||
|
'report'
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -152,6 +153,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'report',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="report"
|
||||||
|
defaultMessage="Report"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -240,6 +250,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'report',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="report"
|
||||||
|
defaultMessage="Report"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentTab != 'list' &&
|
: currentTab != 'list' &&
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue