AI-P1: Weekly Performance Report 1.0

This commit is contained in:
Yinyin Liu 2026-02-18 07:36:00 +01:00
parent c076d55407
commit 77f6e0de6c
13 changed files with 1186 additions and 7 deletions

View File

@ -8,6 +8,7 @@
<ItemGroup>
<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="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<PackageReference Include="Microsoft.AspNet.Identity.Core" Version="2.2.3" />

View File

@ -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))]
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
{

View File

@ -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
}

View File

@ -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; }
}

View File

@ -1,8 +1,8 @@
{
"SmtpServerUrl" : "smtp.gmail.com",
"SmtpUsername" : "angelis@inesco.energy",
"SmtpPassword" : "huvu pkqd kakz hqtm ",
"SmtpServerUrl" : "mail.agenturserver.de",
"SmtpUsername" : "no-reply@inesco.ch",
"SmtpPassword" : "1ci4vi%+bfccIp",
"SmtpPort" : 587,
"SenderName" : "Inesco Energy",
"SenderAddress" : "noreply@inesco.energy"
"SenderAddress" : "no-reply@inesco.ch"
}

View File

@ -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;
}
}

View File

@ -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";
}

View File

@ -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.

View File

@ -20,5 +20,6 @@
"configuration": "configuration",
"history": "history",
"mainstats": "mainstats",
"detailed_view": "detailed_view/"
"detailed_view": "detailed_view/",
"report": "report"
}

View File

@ -27,6 +27,7 @@ import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -430,7 +431,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab != 'overview' &&
currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' && (
currentTab != 'log' &&
currentTab != 'report' && (
<Container
maxWidth="xl"
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
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

@ -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;

View File

@ -30,7 +30,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'manage',
'log',
'history',
'configuration'
'configuration',
'report'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -152,6 +153,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="History Of Actions"
/>
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: [
@ -240,6 +250,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="History Of Actions"
/>
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: currentTab != 'list' &&