added language support for monitor AI and non-AI content and email delivery

This commit is contained in:
Yinyin Liu 2026-02-18 12:12:58 +01:00
parent 77f6e0de6c
commit 8e50220242
21 changed files with 721 additions and 266 deletions

View File

@ -763,7 +763,7 @@ public class Controller : ControllerBase
installation.Product != (int)ProductType.SodiStoreMax)
return BadRequest("AI diagnostics not available for this product.");
var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription);
var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en");
if (result is null)
return NoContent(); // no diagnosis available (not in knowledge base, no API key)
@ -869,7 +869,7 @@ public class Controller : ControllerBase
/// 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)
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
@ -885,7 +885,8 @@ public class Controller : ControllerBase
try
{
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName);
var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
return Ok(report);
}
catch (Exception ex)
@ -911,8 +912,9 @@ public class Controller : ControllerBase
try
{
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName);
await ReportEmailService.SendReportEmailAsync(report, emailAddress);
var lang = user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang);
return Ok(new { message = $"Report sent to {emailAddress}" });
}
catch (Exception ex)

View File

@ -237,26 +237,63 @@ public static class UserMethods
public static Task SendPasswordResetEmail(this User user, String token)
{
const String subject = "Reset the password of your Inesco Energy Account";
const String resetLink = "https://monitor.inesco.energy/api/ResetPassword"; // TODO: move to settings file
var encodedToken = HttpUtility.UrlEncode(token);
var body = $"Dear {user.Name}\n" +
$"To reset your password " +
$"please open this link:{resetLink}?token={encodedToken}";
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Passwort Ihres Inesco Energy Kontos zurücksetzen",
$"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}"
),
"fr" => (
"Réinitialisation du mot de passe de votre compte Inesco Energy",
$"Cher/Chère {user.Name}\n" +
$"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}"
),
"it" => (
"Reimposta la password del tuo account Inesco Energy",
$"Gentile {user.Name}\n" +
$"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}"
),
_ => (
"Reset the password of your Inesco Energy Account",
$"Dear {user.Name}\n" +
$"To reset your password please open this link: {resetLink}?token={encodedToken}"
)
};
return user.SendEmail(subject, body);
}
public static Task SendNewUserWelcomeMessage(this User user)
{
const String subject = "Your new Inesco Energy Account";
var resetLink = $"https://monitor.inesco.energy/?username={user.Email}"; // TODO: move to settings file
var body = $"Dear {user.Name}\n" +
$"To set your password and log in to your " +
$"Inesco Energy Account open this link:{resetLink}";
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Ihr neues Inesco Energy Konto",
$"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
),
"fr" => (
"Votre nouveau compte Inesco Energy",
$"Cher/Chère {user.Name}\n" +
$"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}"
),
"it" => (
"Il tuo nuovo account Inesco Energy",
$"Gentile {user.Name}\n" +
$"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}"
),
_ => (
"Your new Inesco Energy Account",
$"Dear {user.Name}\n" +
$"To set your password and log in to your Inesco Energy Account open this link: {resetLink}"
)
};
return user.SendEmail(subject, body);
}

View File

@ -10,6 +10,7 @@ public class User : TreeNode
public int UserType { get; set; } = 0;
public Boolean MustResetPassword { get; set; } = false;
public String? Password { get; set; } = null!;
public String Language { get; set; } = "en";
[Unique]
public override String Name { get; set; } = null!;

View File

@ -53,6 +53,12 @@ public static partial class Db
Connection.CreateTable<UserAction>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
Connection.Execute("UPDATE User SET Language = 'en' WHERE Language IS NULL OR Language = '' OR Language = 'english'");
Connection.Execute("UPDATE User SET Language = 'de' WHERE Language = 'german'");
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
//UpdateKeys();
CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning();

View File

@ -38,26 +38,40 @@ public static class DiagnosticService
// ── public entry-point ──────────────────────────────────────────
/// <summary>
/// Returns a diagnosis for <paramref name="errorDescription"/>.
/// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms.
/// Falls back to in-memory cache, then calls Mistral AI only for unknown alarms.
/// </summary>
public static async Task<DiagnosticResponse?> DiagnoseAsync(Int64 installationId, string errorDescription)
private static string LanguageName(string code) => code switch
{
// 1. Check the static knowledge base first (no API call needed)
var knownDiagnosis = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription);
if (knownDiagnosis is not null)
"de" => "German",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
/// <summary>
/// Returns a diagnosis for <paramref name="errorDescription"/> in the given language.
/// For English: checks the static AlarmKnowledgeBase first, then in-memory cache, then Mistral AI.
/// For other languages: skips the knowledge base (English-only) and goes directly to Mistral AI.
/// Cache is keyed by (errorDescription, language) so each language is cached separately.
/// </summary>
public static async Task<DiagnosticResponse?> DiagnoseAsync(Int64 installationId, string errorDescription, string language = "en")
{
var cacheKey = $"{errorDescription}|{language}";
// 1. For English only: check the static knowledge base first (no API call needed)
if (language == "en")
{
Console.WriteLine($"[DiagnosticService] Found diagnosis in knowledge base for: {errorDescription}");
return knownDiagnosis;
var knownDiagnosis = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription);
if (knownDiagnosis is not null)
{
Console.WriteLine($"[DiagnosticService] Found diagnosis in knowledge base for: {errorDescription}");
return knownDiagnosis;
}
}
// 2. If AI is not enabled, we can't proceed further
if (!IsEnabled) return null;
// 3. Check in-memory cache for previously fetched AI diagnoses
if (Cache.TryGetValue(errorDescription, out var cached))
if (Cache.TryGetValue(cacheKey, out var cached))
return cached;
// 4. Gather context from the DB for AI prompt
@ -77,14 +91,14 @@ public static class DiagnosticService
.ToList();
// 5. Build prompt and call Mistral API (only for unknown alarms)
Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription}");
var prompt = BuildPrompt(errorDescription, productName, recentDescriptions);
Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription} ({language})");
var prompt = BuildPrompt(errorDescription, productName, recentDescriptions, language);
var response = await CallMistralAsync(prompt);
if (response is null) return null;
// 6. Store in cache for future requests
Cache.TryAdd(errorDescription, response);
Cache.TryAdd(cacheKey, response);
return response;
}
@ -101,7 +115,7 @@ public static class DiagnosticService
if (Cache.TryGetValue(errorDescription, out var cached))
return cached;
var prompt = BuildPrompt(errorDescription, "SodioHome", new List<string>());
var prompt = BuildPrompt(errorDescription, "SodioHome", new List<string>(), "en");
var response = await CallMistralAsync(prompt);
if (response is not null)
@ -112,7 +126,7 @@ public static class DiagnosticService
// ── prompt ──────────────────────────────────────────────────────
private static string BuildPrompt(string errorDescription, string productName, List<string> recentErrors)
private static string BuildPrompt(string errorDescription, string productName, List<string> recentErrors, string language = "en")
{
var recentList = recentErrors.Count > 0
? string.Join(", ", recentErrors)
@ -128,7 +142,7 @@ Explain for a non-technical homeowner. Keep it very short and simple:
- explanation: 1 short sentence, no jargon
- causes: 2-3 bullet points, plain language
- nextSteps: 2-3 simple action items a homeowner can understand
Reply with ONLY valid JSON, no markdown:
IMPORTANT: Write all text values in {LanguageName(language)}. Reply with ONLY valid JSON, no markdown:
{{""explanation"":""1 short sentence"",""causes"":[""...""],""nextSteps"":[""...""]}}
";
}

View File

@ -10,13 +10,14 @@ namespace InnovEnergy.App.Backend.Services;
public static class ReportEmailService
{
/// <summary>
/// Sends the weekly report as a nicely formatted HTML email.
/// Sends the weekly report as a nicely formatted HTML email in the user's language.
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
/// </summary>
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail)
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en")
{
var subject = $"Weekly Energy Report — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
var html = BuildHtmlEmail(report);
var strings = GetStrings(language);
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
var html = BuildHtmlEmail(report, strings);
var config = await ReadMailerConfig();
@ -49,9 +50,169 @@ public static class ReportEmailService
return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json");
}
// ── Translation strings ─────────────────────────────────────────────────
private record EmailStrings(
string Title,
string Insights,
string Summary,
string SavingsHeader,
string DailyBreakdown,
string Metric,
string ThisWeek,
string LastWeek,
string Change,
string PvProduction,
string Consumption,
string GridImport,
string GridExport,
string BatteryInOut,
string SolarEnergyUsed,
string StayedAtHome,
string EstMoneySaved,
string AtRate,
string SolarCoverage,
string FromSolar,
string BatteryEff,
string OutVsIn,
string Day,
string Load,
string GridIn,
string GridOut,
string BattInOut,
string Footer
);
private static EmailStrings GetStrings(string language) => language switch
{
"de" => new EmailStrings(
Title: "Wöchentlicher Leistungsbericht",
Insights: "Wöchentliche Erkenntnisse",
Summary: "Wöchentliche Zusammenfassung",
SavingsHeader: "Ihre Ersparnisse diese Woche",
DailyBreakdown: "Tägliche Aufschlüsselung (kWh)",
Metric: "Kennzahl",
ThisWeek: "Diese Woche",
LastWeek: "Letzte Woche",
Change: "Änderung",
PvProduction: "PV-Produktion",
Consumption: "Verbrauch",
GridImport: "Netzbezug",
GridExport: "Netzeinspeisung",
BatteryInOut: "Batterie Ein/Aus",
SolarEnergyUsed: "Genutzte Solarenergie",
StayedAtHome: "direkt genutzt",
EstMoneySaved: "Geschätzte Ersparnis",
AtRate: "bei 0.27 CHF/kWh",
SolarCoverage: "Solare Deckung",
FromSolar: "durch Solar",
BatteryEff: "Batterie-Eff.",
OutVsIn: "Aus vs. Ein",
Day: "Tag",
Load: "Last",
GridIn: "Netz Ein",
GridOut: "Netz Aus",
BattInOut: "Batt. Ein/Aus",
Footer: "Erstellt von <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI"
),
"fr" => new EmailStrings(
Title: "Rapport de performance hebdomadaire",
Insights: "Aperçus de la semaine",
Summary: "Résumé de la semaine",
SavingsHeader: "Vos économies cette semaine",
DailyBreakdown: "Détail quotidien (kWh)",
Metric: "Indicateur",
ThisWeek: "Cette semaine",
LastWeek: "Semaine dernière",
Change: "Variation",
PvProduction: "Production PV",
Consumption: "Consommation",
GridImport: "Import réseau",
GridExport: "Export réseau",
BatteryInOut: "Batterie Entrée/Sortie",
SolarEnergyUsed: "Énergie solaire utilisée",
StayedAtHome: "autoconsommée",
EstMoneySaved: "Économies estimées",
AtRate: "à 0.27 CHF/kWh",
SolarCoverage: "Couverture solaire",
FromSolar: "depuis le solaire",
BatteryEff: "Eff. batterie",
OutVsIn: "sortie vs entrée",
Day: "Jour",
Load: "Charge",
GridIn: "Réseau Ent.",
GridOut: "Réseau Sor.",
BattInOut: "Batt. Ent./Sor.",
Footer: "Généré par <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Propulsé par Mistral AI"
),
"it" => new EmailStrings(
Title: "Rapporto settimanale delle prestazioni",
Insights: "Approfondimenti settimanali",
Summary: "Riepilogo settimanale",
SavingsHeader: "I tuoi risparmi questa settimana",
DailyBreakdown: "Dettaglio giornaliero (kWh)",
Metric: "Metrica",
ThisWeek: "Questa settimana",
LastWeek: "La settimana scorsa",
Change: "Variazione",
PvProduction: "Produzione PV",
Consumption: "Consumo",
GridImport: "Import dalla rete",
GridExport: "Export nella rete",
BatteryInOut: "Batteria Ent./Usc.",
SolarEnergyUsed: "Energia solare utilizzata",
StayedAtHome: "rimasta in casa",
EstMoneySaved: "Risparmio stimato",
AtRate: "a 0.27 CHF/kWh",
SolarCoverage: "Copertura solare",
FromSolar: "dal solare",
BatteryEff: "Eff. batteria",
OutVsIn: "uscita vs entrata",
Day: "Giorno",
Load: "Carico",
GridIn: "Rete Ent.",
GridOut: "Rete Usc.",
BattInOut: "Batt. Ent./Usc.",
Footer: "Generato da <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI"
),
_ => new EmailStrings(
Title: "Weekly Performance Report",
Insights: "Weekly Insights",
Summary: "Weekly Summary",
SavingsHeader: "Your Savings This Week",
DailyBreakdown: "Daily Breakdown (kWh)",
Metric: "Metric",
ThisWeek: "This Week",
LastWeek: "Last Week",
Change: "Change",
PvProduction: "PV Production",
Consumption: "Consumption",
GridImport: "Grid Import",
GridExport: "Grid Export",
BatteryInOut: "Battery In/Out",
SolarEnergyUsed: "Solar Energy Used",
StayedAtHome: "stayed at home",
EstMoneySaved: "Est. Money Saved",
AtRate: "at 0.27 CHF/kWh",
SolarCoverage: "Solar Coverage",
FromSolar: "from solar",
BatteryEff: "Battery Eff.",
OutVsIn: "out vs in",
Day: "Day",
Load: "Load",
GridIn: "Grid In",
GridOut: "Grid Out",
BattInOut: "Batt In/Out",
Footer: "Generated by <strong style=\"color:#666\">Inesco Energy Monitor Platform</strong> · Powered by Mistral AI"
)
};
// ── HTML email template ─────────────────────────────────────────────
public static string BuildHtmlEmail(WeeklyReportResponse r)
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en")
=> BuildHtmlEmail(r, GetStrings(language));
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s)
{
var cur = r.CurrentWeek;
var prev = r.PreviousWeek;
@ -91,47 +252,47 @@ public static class ReportEmailService
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"">{s.PvProduction}</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"">{s.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"">{s.GridImport}</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"">{s.GridExport}</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"">{s.BatteryInOut}</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>";
<tr><td style=""padding:8px 12px"">{s.PvProduction}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.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"">{s.GridImport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.GridExport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</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>";
? $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>
<th style=""padding:8px 12px;text-align:right"">{s.LastWeek}</th>
<th style=""padding:8px 12px;text-align:right"">{s.Change}</th>"
: $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>";
return $@"
<!DOCTYPE html>
@ -145,7 +306,7 @@ public static class ReportEmailService
<!-- 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:20px;font-weight:bold"">{s.Title}</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>
@ -154,7 +315,7 @@ public static class ReportEmailService
<!-- 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=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
{insightHtml}
</div>
@ -164,10 +325,10 @@ public static class ReportEmailService
<!-- Weekly Totals -->
<tr>
<td style=""padding:24px 30px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">Weekly Summary</div>
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
<tr style=""background:#f8f9fa"">
<th style=""padding:8px 12px;text-align:left"">Metric</th>
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
{comparisonHeaders}
</tr>
{comparisonHtml}
@ -178,13 +339,13 @@ public static class ReportEmailService
<!-- 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>
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
<tr>
{SavingsBox("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")}
{SavingsBox(s.SolarEnergyUsed, $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", s.StayedAtHome, "#27ae60")}
{SavingsBox(s.EstMoneySaved, $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", s.AtRate, "#2980b9")}
{SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")}
{SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
</tr>
</table>
</td>
@ -193,15 +354,15 @@ public static class ReportEmailService
<!-- 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>
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.DailyBreakdown}</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:left"">{s.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>
<th style=""padding:6px 10px;text-align:right"">{s.Load}</th>
<th style=""padding:6px 10px;text-align:right"">{s.GridIn}</th>
<th style=""padding:6px 10px;text-align:right"">{s.GridOut}</th>
<th style=""padding:6px 10px;text-align:right"">{s.BattInOut}</th>
</tr>
{dailyRows}
</table>
@ -211,7 +372,7 @@ public static class ReportEmailService
<!-- 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
{s.Footer}
</td>
</tr>

View File

@ -11,14 +11,17 @@ public static class WeeklyReportService
private static readonly ConcurrentDictionary<string, string> InsightCache = new();
// Bump this version when the AI prompt changes to automatically invalidate old cache files
private const string CacheVersion = "v2";
/// <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.
/// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated or CacheVersion changes.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName)
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
{
var xlsxPath = TmpReportDir + installationId + ".xlsx";
var cachePath = TmpReportDir + installationId + ".cache.json";
var cachePath = TmpReportDir + $"{installationId}_{language}_{CacheVersion}.cache.json";
// Use cached report if xlsx hasn't changed since cache was written
if (File.Exists(cachePath) && File.Exists(xlsxPath))
@ -33,7 +36,7 @@ public static class WeeklyReportService
await File.ReadAllTextAsync(cachePath));
if (cached != null)
{
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId}.");
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}).");
return cached;
}
}
@ -45,7 +48,7 @@ public static class WeeklyReportService
}
var allDays = ExcelDataParser.Parse(xlsxPath);
var report = await GenerateReportFromDataAsync(allDays, installationName);
var report = await GenerateReportFromDataAsync(allDays, installationName, language);
// Write cache
try
@ -64,7 +67,7 @@ public static class WeeklyReportService
/// Core report generation from daily data. Data-source agnostic.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> allDays, string installationName)
List<DailyEnergyData> allDays, string installationName, string language = "en")
{
// Sort by date
allDays = allDays.OrderBy(d => d.Date).ToList();
@ -111,7 +114,7 @@ public static class WeeklyReportService
// AI insight
var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary,
selfSufficiency, gridDependency, batteryEfficiency, installationName);
selfSufficiency, gridDependency, batteryEfficiency, installationName, language);
return new WeeklyReportResponse
{
@ -152,6 +155,14 @@ public static class WeeklyReportService
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
private static string LanguageName(string code) => code switch
{
"de" => "German",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
private static async Task<string> GetAiInsightAsync(
List<DailyEnergyData> currentWeek,
WeeklySummary current,
@ -159,7 +170,8 @@ public static class WeeklyReportService
double selfSufficiency,
double gridDependency,
double batteryEfficiency,
string installationName)
string installationName,
string language = "en")
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
@ -168,8 +180,8 @@ public static class WeeklyReportService
return "AI insight unavailable (API key not configured).";
}
// Cache key: installation + period
var cacheKey = $"{installationName}_{currentWeek.Last().Date}";
// Cache key: installation + period + language
var cacheKey = $"{installationName}_{currentWeek.Last().Date}_{language}";
if (InsightCache.TryGetValue(cacheKey, out var cached))
return cached;
@ -190,12 +202,15 @@ public static class WeeklyReportService
Write exactly 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown plain text only.
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
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.
IMPORTANT: Write your entire response in {LanguageName(language)}.
Daily data (kWh):
{dayLines}

View File

@ -1,21 +1,22 @@
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import { CssBaseline } from '@mui/material';
import ThemeProvider from './theme/ThemeProvider';
import React, { lazy, Suspense, useContext, useState } from 'react';
import React, { lazy, Suspense, useContext, useEffect, useState } from 'react';
import { UserContext } from './contexts/userContext';
import Login from './components/login';
import { IntlProvider } from 'react-intl';
import en from './lang/en.json';
import de from './lang/de.json';
import fr from './lang/fr.json';
import it from './lang/it.json';
import SuspenseLoader from './components/SuspenseLoader';
import axiosConfig, { axiosConfigWithoutToken } from './Resources/axiosConfig';
import SidebarLayout from './layouts/SidebarLayout';
import { TokenContext } from './contexts/tokenContext';
import InstallationTabs from './content/dashboards/Installations/index';
import routes from 'src/Resources/routes.json';
import './App.css';
import ForgotPassword from './components/ForgotPassword';
import { axiosConfigWithoutToken } from './Resources/axiosConfig';
import InstallationsContextProvider from './contexts/InstallationsContextProvider';
import AccessContextProvider from './contexts/AccessContextProvider';
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
@ -37,15 +38,38 @@ function App() {
setAccessToSodistore
} = useContext(ProductIdContext);
const [language, setLanguage] = useState('en');
const [language, setLanguage] = useState<string>(
() => localStorage.getItem('language') || currentUser?.language || 'en'
);
const onSelectLanguage = (lang: string) => {
setLanguage(lang);
localStorage.setItem('language', lang);
if (currentUser) {
const updatedUser = { ...currentUser, language: lang };
setUser(updatedUser);
axiosConfig.put('/UpdateUser', updatedUser).catch(() => {});
}
};
// Sync localStorage language to DB when it differs (e.g. user changed language before new code was deployed)
useEffect(() => {
if (currentUser && token) {
const storedLang = localStorage.getItem('language');
if (storedLang && storedLang !== currentUser.language) {
const updatedUser = { ...currentUser, language: storedLang };
setUser(updatedUser);
axiosConfig.put('/UpdateUser', updatedUser).catch(() => {});
}
}
}, [token]);
const getTranslations = () => {
switch (language) {
case 'en':
return en;
case 'de':
return de;
case 'fr':
return fr;
case 'de': return de;
case 'fr': return fr;
case 'it': return it;
default: return en;
}
};
@ -151,7 +175,7 @@ function App() {
element={
<SidebarLayout
language={language}
onSelectLanguage={setLanguage}
onSelectLanguage={onSelectLanguage}
/>
}
>

View File

@ -1,4 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import {
Box,
@ -36,6 +37,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
if (props.batteryData === null) {
return null;
}
const intl = useIntl();
const navigate = useNavigate();
const [openModalFirmwareUpdate, setOpenModalFirmwareUpdate] = useState(false);
const [openModalResultFirmwareUpdate, setOpenModalResultFirmwareUpdate] =
@ -242,7 +244,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}
} catch (error) {
console.error('Error:', error.message);
setErrorMessage('Download battery log failed, please try again.');
setErrorMessage(intl.formatMessage({ id: 'downloadBatteryLogFailed' }));
setOpenModalError(true);
} finally {
setOpenModalStartDownloadBatteryLog(false);
@ -282,7 +284,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The firmware is getting updated. Please wait...
<FormattedMessage id="firmwareUpdating" defaultMessage="The firmware is getting updated. Please wait..." />
</Typography>
<div
@ -302,7 +304,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={firmwareModalResultHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -337,12 +339,11 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to update the firmware?
<FormattedMessage id="confirmFirmwareUpdate" defaultMessage="Do you really want to update the firmware?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -362,7 +363,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -375,7 +376,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -409,8 +410,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The battery log is getting downloaded. It will be saved in the
Downloads folder. Please wait...
<FormattedMessage id="downloadingBatteryLog" defaultMessage="The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait..." />
</Typography>
<div
@ -430,7 +430,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={StartDownloadBatteryLogModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -465,12 +465,11 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to download battery log?
<FormattedMessage id="confirmBatteryLogDownload" defaultMessage="Do you really want to download battery log?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -490,7 +489,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -503,7 +502,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -553,7 +552,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={ErrorModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>

View File

@ -1,4 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import {
Box,
@ -36,7 +37,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
if (props.batteryData === null) {
return null;
}
const intl = useIntl();
const navigate = useNavigate();
const [openModalFirmwareUpdate, setOpenModalFirmwareUpdate] = useState(false);
const [openModalResultFirmwareUpdate, setOpenModalResultFirmwareUpdate] =
@ -243,7 +244,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}
} catch (error) {
console.error('Error:', error.message);
setErrorMessage('Download battery log failed, please try again.');
setErrorMessage(intl.formatMessage({ id: 'downloadBatteryLogFailed' }));
setOpenModalError(true);
} finally {
setOpenModalStartDownloadBatteryLog(false);
@ -283,7 +284,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The firmware is getting updated. Please wait...
<FormattedMessage id="firmwareUpdating" defaultMessage="The firmware is getting updated. Please wait..." />
</Typography>
<div
@ -303,7 +304,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={firmwareModalResultHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -338,12 +339,11 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to update the firmware?
<FormattedMessage id="confirmFirmwareUpdate" defaultMessage="Do you really want to update the firmware?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -363,7 +363,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -376,7 +376,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -410,8 +410,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The battery log is getting downloaded. It will be saved in the
Downloads folder. Please wait...
<FormattedMessage id="downloadingBatteryLog" defaultMessage="The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait..." />
</Typography>
<div
@ -431,7 +430,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={StartDownloadBatteryLogModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -466,12 +465,11 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to download battery log?
<FormattedMessage id="confirmBatteryLogDownload" defaultMessage="Do you really want to download battery log?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -491,7 +489,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -504,7 +502,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -554,7 +552,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={ErrorModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>

View File

@ -494,7 +494,7 @@ function Log(props: LogProps) {
{diag.description}
</Typography>
<Typography variant="caption" color="text.secondary">
Last seen: {diag.lastSeen}
<FormattedMessage id="lastSeen" defaultMessage="Last seen" />: {diag.lastSeen}
</Typography>
</Box>

View File

@ -26,7 +26,7 @@ import PersonIcon from '@mui/icons-material/Person';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { UserType } from '../../../interfaces/UserTypes';
interface AccessProps {
@ -35,6 +35,7 @@ interface AccessProps {
}
function Access(props: AccessProps) {
const intl = useIntl();
const theme = useTheme();
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
@ -159,31 +160,11 @@ function Access(props: AccessProps) {
if (NotGrantedAccessUsers.length > 0) {
setError(true);
const message =
(
<FormattedMessage
id="unableToGrantAccess"
defaultMessage="Unable to grant access to: "
/>
).props.defaultMessage +
' ' +
NotGrantedAccessUsers.join(', ');
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToGrantAccess' }) + ' ' + NotGrantedAccessUsers.join(', '));
}
if (grantedAccessUsers.length > 0) {
const message =
(
<FormattedMessage
id="grantedAccessToUsers"
defaultMessage="Granted access to users: "
/>
).props.defaultMessage +
' ' +
grantedAccessUsers.join(', ');
setUpdatedMessage(message);
setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUsers' }) + ' ' + grantedAccessUsers.join(', '));
setUpdated(true);
@ -306,7 +287,7 @@ function Access(props: AccessProps) {
}}
onClick={handleCloseFolder}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</Select>
</FormControl>

View File

@ -29,7 +29,7 @@ import PersonIcon from '@mui/icons-material/Person';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { InnovEnergyUser, UserType } from '../../../interfaces/UserTypes';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import {
@ -47,6 +47,7 @@ function UserAccess(props: UserAccessProps) {
return null;
}
const intl = useIntl();
const theme = useTheme();
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
@ -165,20 +166,12 @@ function UserAccess(props: UserAccessProps) {
)
.then((response) => {
if (response) {
setUpdatedMessage(
'Granted access to user ' + props.current_user.name
);
setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUser' }, { name: props.current_user.name }));
setUpdated(true);
}
})
.catch((err) => {
if (err.response && err.response.status === 401) {
setErrorMessage(
`User ${props.current_user.name} already has access to folder "${folder.name}" or you don't have permission to grant this access`
);
} else {
setErrorMessage('An error has occured');
}
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
setError(true);
});
}
@ -194,20 +187,12 @@ function UserAccess(props: UserAccessProps) {
)
.then((response) => {
if (response) {
setUpdatedMessage(
'Granted access to user ' + props.current_user.name
);
setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUser' }, { name: props.current_user.name }));
setUpdated(true);
}
})
.catch((err) => {
if (err.response && err.response.status === 401) {
setErrorMessage(
`User ${props.current_user.name} already has access to installation "${installation.name}" or you don't have permission to grant this access`
);
} else {
setErrorMessage('An error has occured');
}
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
setError(true);
});
}

View File

@ -621,7 +621,7 @@ function Overview(props: OverviewProps) {
</Container>
)}
{!loading && (
{!loading && dailyDataArray.length > 0 && (
<Grid item xs={12} md={12}>
{dailyData && (
<Grid

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import {
Box,
Button,
@ -77,6 +78,7 @@ function FormattedBullet({ text }: { text: string }) {
}
function WeeklyReport({ installationId }: WeeklyReportProps) {
const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -89,14 +91,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
useEffect(() => {
fetchReport();
}, [installationId]);
}, [installationId, intl.locale]);
const fetchReport = async () => {
setLoading(true);
setError(null);
try {
const res = await axiosConfig.get('/GetWeeklyReport', {
params: { installationId }
params: { installationId, language: intl.locale }
});
setReport(res.data);
} catch (err: any) {
@ -117,9 +119,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
await axiosConfig.post('/SendWeeklyReportEmail', null, {
params: { installationId, emailAddress: email.trim() }
});
setSendStatus({ message: `Report sent to ${email}`, severity: 'success' });
setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' });
} catch (err: any) {
setSendStatus({ message: 'Failed to send. Please check the email address and try again.', severity: 'error' });
setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' });
} finally {
setSending(false);
}
@ -138,7 +140,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
>
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
<Typography variant="body2" mt={2}>
Generating weekly report...
<FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." />
</Typography>
</Container>
);
@ -147,7 +149,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
if (error) {
return (
<Container sx={{ py: 4 }}>
<Alert severity="warning">{error}</Alert>
<Alert severity="warning"><FormattedMessage id="noReportData" defaultMessage="No report data found." /></Alert>
</Container>
);
}
@ -201,7 +203,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
disabled={sending || !email.trim()}
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
>
Send Report
<FormattedMessage id="sendReport" defaultMessage="Send Report" />
</Button>
</Box>
{sendStatus && (
@ -222,7 +224,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
}}
>
<Typography variant="h5" fontWeight="bold">
Weekly Performance Report
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
</Typography>
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
{report.installationName}
@ -235,7 +237,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{/* Weekly Insights (was AI Insights) */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
Weekly Insights
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
</Typography>
<Box
sx={{
@ -262,38 +264,38 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{/* Your Savings This Week */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
Your Savings This Week
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
</Typography>
<Grid container spacing={2}>
<Grid item xs={6} sm={3}>
<SavingsCard
label="Solar Energy Used"
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
value={`${solarSavingsKwh} kWh`}
subtitle="of your solar stayed at home"
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
color="#27ae60"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label="Est. Money Saved"
label={intl.formatMessage({ id: 'estMoneySaved' })}
value={`~${estimatedSavingsCHF} CHF`}
subtitle="at 0.27 CHF/kWh avg."
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
color="#2980b9"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label="Solar Coverage"
label={intl.formatMessage({ id: 'solarCoverage' })}
value={`${report.selfSufficiencyPercent.toFixed(0)}%`}
subtitle="of consumption from solar"
subtitle={intl.formatMessage({ id: 'fromSolarSub' })}
color="#8e44ad"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label="Battery Efficiency"
label={intl.formatMessage({ id: 'batteryEfficiency' })}
value={`${report.batteryEfficiencyPercent.toFixed(0)}%`}
subtitle="energy out vs energy in"
subtitle={intl.formatMessage({ id: 'batteryEffSub' })}
color="#e67e22"
/>
</Grid>
@ -303,44 +305,44 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{/* Weekly Summary Table */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
Weekly Summary
<FormattedMessage id="weeklySummary" defaultMessage="Weekly Summary" />
</Typography>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', '& td, & th': { p: 1.2, borderBottom: '1px solid #eee' } }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ textAlign: 'left' }}>Metric</th>
<th style={{ textAlign: 'right' }}>This Week</th>
{prev && <th style={{ textAlign: 'right' }}>Last Week</th>}
{prev && <th style={{ textAlign: 'right' }}>Change</th>}
<th style={{ textAlign: 'left' }}><FormattedMessage id="metric" defaultMessage="Metric" /></th>
<th style={{ textAlign: 'right' }}><FormattedMessage id="thisWeek" defaultMessage="This Week" /></th>
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="lastWeek" defaultMessage="Last Week" /></th>}
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="change" defaultMessage="Change" /></th>}
</tr>
</thead>
<tbody>
<tr>
<td>PV Production</td>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalPvProduction.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalPvProduction.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.pvChangePercent) }}>{formatChange(report.pvChangePercent)}</td>}
</tr>
<tr>
<td>Consumption</td>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalConsumption.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalConsumption.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
</tr>
<tr>
<td>Grid Import</td>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridImport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridImport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.gridImportChangePercent, true) }}>{formatChange(report.gridImportChangePercent)}</td>}
</tr>
<tr>
<td>Grid Export</td>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridExport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridExport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right' }}></td>}
</tr>
<tr>
<td>Battery In / Out</td>
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right' }}></td>}
@ -353,18 +355,18 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{report.dailyData.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
Daily Breakdown
<FormattedMessage id="dailyBreakdown" defaultMessage="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 sx={{ width: 12, height: 12, bgcolor: '#f39c12', borderRadius: '2px' }} /> <FormattedMessage id="pvProduction" defaultMessage="PV Production" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#3498db', borderRadius: '2px' }} /> Consumption
<Box sx={{ width: 12, height: 12, bgcolor: '#3498db', borderRadius: '2px' }} /> <FormattedMessage id="consumption" defaultMessage="Consumption" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> Grid Import
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
</Box>
</Box>
{/* Bars */}
@ -377,7 +379,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
<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>}
{!isCurrentWeek && <span style={{ color: '#999', marginLeft: 4 }}><FormattedMessage id="prevWeek" defaultMessage="(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

View File

@ -11,7 +11,7 @@ import {
I_UserWithInheritedAccess,
InnovEnergyUser
} from '../interfaces/UserTypes';
import { FormattedMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import { I_Installation } from '../interfaces/InstallationTypes';
interface AccessContextProviderProps {
@ -69,10 +69,11 @@ export const AccessContext = createContext<AccessContextProviderProps>({
});
const AccessContextProvider = ({ children }: { children: ReactNode }) => {
const intl = useIntl();
const [error, setError] = useState(false);
const [errormessage, setErrorMessage] = useState('An error has occured');
const [errormessage, setErrorMessage] = useState('');
const [updated, setUpdated] = useState(false);
const [updatedmessage, setUpdatedMessage] = useState('Successfully updated');
const [updatedmessage, setUpdatedMessage] = useState('');
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
const [usersWithDirectAccess, setUsersWithDirectAccess] = useState<
@ -95,20 +96,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
setUsersWithDirectAccess(response.data);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToLoadData"
defaultMessage="Unable to load data"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' }));
});
},
[]
[intl]
);
const fetchInstallationsForUser = useCallback(async (userId: number) => {
@ -119,17 +112,11 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
setAccessibleInstallationsForUser(response.data);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToLoadData"
defaultMessage="Unable to load data"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' }));
});
}, []);
}, [intl]);
const fetchUsersWithInheritedAccessForResource = useCallback(
async (tempresourceType: string, id: number) => {
@ -140,18 +127,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
setUsersWithInheritedAccess(response.data);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToLoadData"
defaultMessage="Unable to load data"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' }));
});
},
[]
[intl]
);
const fetchAvailableUsers = async (): Promise<void> => {
@ -183,17 +164,7 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
current_ResourceId
);
const message =
(
<FormattedMessage
id="revokedAccessFromUser"
defaultMessage="Revoked access from user: "
/>
).props.defaultMessage +
' ' +
name;
setUpdatedMessage(message);
setUpdatedMessage(intl.formatMessage({ id: 'revokedAccessFromUser' }) + ' ' + name);
setUpdated(true);
setTimeout(() => {
@ -201,19 +172,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
}, 3000);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToRevokeAccess"
defaultMessage="Unable to revoke access"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToRevokeAccess' }));
});
},
[]
[intl, fetchUsersWithDirectAccessForResource, fetchUsersWithInheritedAccessForResource]
);
return (

View File

@ -13,7 +13,9 @@
"english": "Englisch",
"error": "Fehler",
"folder": "Ordner",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"groupTabs": "Gruppen",
"groupTree": "Gruppenbaum",
"overview": "Überblick",
@ -89,5 +91,44 @@
"unableToGrantAccess": "Der Zugriff kann nicht gewährt werden",
"unableToLoadData": "Daten können nicht geladen werden",
"unableToRevokeAccess": "Der Zugriff konnte nicht widerrufen werden",
"revokedAccessFromUser": "Zugriff vom Benutzer widerrufen"
"revokedAccessFromUser": "Zugriff vom Benutzer widerrufen",
"Show Errors": "Fehler anzeigen",
"Show Warnings": "Warnungen anzeigen",
"lastSeen": "Zuletzt gesehen",
"reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke",
"weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Genutzte Solarenergie",
"solarStayedHome": "Ihrer Solarenergie blieb zu Hause",
"estMoneySaved": "Geschätzte Ersparnisse",
"atCHFRate": "bei 0,27 CHF/kWh Ø",
"solarCoverage": "Solarabdeckung",
"fromSolarSub": "des Verbrauchs aus Solar",
"batteryEfficiency": "Batterieeffizienz",
"batteryEffSub": "Energie aus vs. Energie ein",
"weeklySummary": "Wöchentliche Zusammenfassung",
"metric": "Kennzahl",
"thisWeek": "Diese Woche",
"change": "Änderung",
"pvProduction": "PV-Produktion",
"consumption": "Verbrauch",
"gridImport": "Netzbezug",
"gridExport": "Netzeinspeisung",
"batteryInOut": "Batterie Ein / Aus",
"dailyBreakdown": "Tägliche Aufschlüsselung",
"prevWeek": "(Vorwoche)",
"sendReport": "Bericht senden",
"generatingReport": "Wochenbericht wird erstellt...",
"reportSentTo": "Bericht gesendet an {email}",
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
"ok": "Ok",
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
"proceed": "Fortfahren",
"firmwareUpdating": "Firmware wird aktualisiert. Bitte warten...",
"confirmFirmwareUpdate": "Möchten Sie die Firmware wirklich aktualisieren?",
"batteryServiceStopWarning": "Diese Aktion erfordert, dass der Batteriedienst ca. 10-15 Minuten gestoppt wird.",
"downloadingBatteryLog": "Das Batterieprotokoll wird heruntergeladen. Es wird im Downloads-Ordner gespeichert. Bitte warten...",
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
"noReportData": "Keine Berichtsdaten gefunden."
}

View File

@ -5,6 +5,9 @@
"customerName": "Customer name",
"english": "English",
"german": "German",
"french": "French",
"italian": "Italian",
"language": "Language",
"installation": "Installation",
"location": "Location",
"log": "Log",
@ -70,5 +73,44 @@
"unableToGrantAccess": "Unable to grant access to: ",
"unableToLoadData": "Unable to load data",
"unableToRevokeAccess": "Unable to revoke access",
"revokedAccessFromUser": "Revoked access from user: "
"revokedAccessFromUser": "Revoked access from user: ",
"Show Errors": "Show Errors",
"Show Warnings": "Show Warnings",
"lastSeen": "Last seen",
"reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights",
"weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Solar Energy Used",
"solarStayedHome": "of your solar stayed at home",
"estMoneySaved": "Est. Money Saved",
"atCHFRate": "at 0.27 CHF/kWh avg.",
"solarCoverage": "Solar Coverage",
"fromSolarSub": "of consumption from solar",
"batteryEfficiency": "Battery Efficiency",
"batteryEffSub": "energy out vs energy in",
"weeklySummary": "Weekly Summary",
"metric": "Metric",
"thisWeek": "This Week",
"change": "Change",
"pvProduction": "PV Production",
"consumption": "Consumption",
"gridImport": "Grid Import",
"gridExport": "Grid Export",
"batteryInOut": "Battery In / Out",
"dailyBreakdown": "Daily Breakdown",
"prevWeek": "(prev week)",
"sendReport": "Send Report",
"generatingReport": "Generating weekly report...",
"reportSentTo": "Report sent to {email}",
"reportSendError": "Failed to send. Please check the email address and try again.",
"ok": "Ok",
"grantedAccessToUser": "Granted access to user {name}",
"proceed": "Proceed",
"firmwareUpdating": "The firmware is getting updated. Please wait...",
"confirmFirmwareUpdate": "Do you really want to update the firmware?",
"batteryServiceStopWarning": "This action requires the battery service to be stopped for around 10-15 minutes.",
"downloadingBatteryLog": "The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait...",
"confirmBatteryLogDownload": "Do you really want to download battery log?",
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
"noReportData": "No report data found."
}

View File

@ -11,7 +11,10 @@
"english": "Anglais",
"error": "Erreur",
"folder": "Dossier",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"language": "Langue",
"overview": "Aperçu",
"manage": "Gestion des accès",
"configuration": "Configuration",
@ -19,7 +22,6 @@
"apply_changes": "Appliquer",
"delete_user": "Supprimer l'utilisateur",
"installation_name_simple": "Nom de l'installation: ",
"language": "Langue",
"minimum_soc": "Soc minimum",
"calibration_charge_forced": "Charge d'étalonnage forcée",
"grid_set_point": "Point de consigne de grid",
@ -41,7 +43,7 @@
"lastWeek": "La semaine dernière",
"location": "Localité",
"log": "Journal",
"logout": "Fermer las session",
"logout": "Fermer la session",
"makeASelection": "Veuillez faire une sélection à gauche",
"manageAccess": "Gérer l'accès",
"move": "Déplacer",
@ -63,7 +65,7 @@
"status": "Statut",
"live": "Diffusion en direct",
"deleteInstallation": "Supprimer l'installation",
"errorOccured": "Une erreur sest produite",
"errorOccured": "Une erreur s'est produite",
"successfullyUpdated": "Mise à jour réussie",
"grantAccess": "Accorder l'accès",
"UserswithDirectAccess": "Utilisateurs avec accès direct",
@ -83,5 +85,44 @@
"unableToGrantAccess": "Impossible d'accorder l'accès à",
"unableToLoadData": "Impossible de charger les données",
"unableToRevokeAccess": "Impossible de révoquer l'accès",
"revokedAccessFromUser": "Accès révoqué de l'utilisateur"
}
"revokedAccessFromUser": "Accès révoqué de l'utilisateur",
"Show Errors": "Afficher les erreurs",
"Show Warnings": "Afficher les avertissements",
"lastSeen": "Dernière connexion",
"reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires",
"weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie solaire utilisée",
"solarStayedHome": "de votre solaire est resté à la maison",
"estMoneySaved": "Économies estimées",
"atCHFRate": "à 0,27 CHF/kWh moy.",
"solarCoverage": "Couverture solaire",
"fromSolarSub": "de la consommation provenant du solaire",
"batteryEfficiency": "Efficacité de la batterie",
"batteryEffSub": "énergie sortante vs énergie entrante",
"weeklySummary": "Résumé hebdomadaire",
"metric": "Métrique",
"thisWeek": "Cette semaine",
"change": "Variation",
"pvProduction": "Production PV",
"consumption": "Consommation",
"gridImport": "Importation réseau",
"gridExport": "Exportation réseau",
"batteryInOut": "Batterie Entrée / Sortie",
"dailyBreakdown": "Répartition quotidienne",
"prevWeek": "(semaine précédente)",
"sendReport": "Envoyer le rapport",
"generatingReport": "Génération du rapport hebdomadaire...",
"reportSentTo": "Rapport envoyé à {email}",
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
"ok": "Ok",
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
"proceed": "Continuer",
"firmwareUpdating": "Le firmware est en cours de mise à jour. Veuillez patienter...",
"confirmFirmwareUpdate": "Voulez-vous vraiment mettre à jour le firmware?",
"batteryServiceStopWarning": "Cette action nécessite l'arrêt du service batterie pendant environ 10-15 minutes.",
"downloadingBatteryLog": "Le journal de la batterie est en cours de téléchargement. Il sera enregistré dans le dossier Téléchargements. Veuillez patienter...",
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
"noReportData": "Aucune donnée de rapport trouvée."
}

View File

@ -0,0 +1,139 @@
{
"allInstallations": "Tutte le installazioni",
"applyChanges": "Applica modifiche",
"country": "Paese",
"customerName": "Nome cliente",
"english": "Inglese",
"german": "Tedesco",
"french": "Francese",
"italian": "Italiano",
"language": "Lingua",
"installation": "Installazione",
"location": "Posizione",
"log": "Registro",
"orderNumbers": "Numeri d'ordine",
"region": "Regione",
"search": "Cerca",
"users": "Utenti",
"logout": "Disconnetti",
"updatedSuccessfully": "Aggiornamento riuscito",
"groups": "Gruppi",
"group": "Gruppo",
"folder": "Cartella",
"updateFolderErrorMessage": "Impossibile aggiornare la cartella, si è verificato un errore",
"Information": "Informazioni",
"addNewChild": "Aggiungi nuovo figlio",
"addNewDialogButton": "Aggiungi nuovo pulsante di dialogo",
"addUser": "Crea utente",
"createNewFolder": "Crea nuova cartella",
"createNewUser": "Crea nuovo utente",
"email": "Email",
"error": "Errore",
"groupTabs": "Schede gruppo",
"groupTree": "Albero gruppo",
"information": "Informazioni",
"inheritedAccess": "Accesso ereditato da",
"installationTabs": "Schede installazione",
"installations": "Installazioni",
"lastWeek": "Settimana scorsa",
"makeASelection": "Effettuare una selezione a sinistra",
"manageAccess": "Gestisci accesso",
"move": "Sposta",
"moveTo": "Sposta in",
"moveTree": "Sposta albero",
"name": "Nome",
"navigationTabs": "Schede di navigazione",
"requiredLocation": "La posizione è obbligatoria",
"requiredName": "Il nome è obbligatorio",
"requiredRegion": "La regione è obbligatoria",
"requiredOrderNumber": "Numero d'ordine obbligatorio",
"submit": "Invia",
"user": "Utente",
"userTabs": "Schede utente",
"status": "Stato",
"live": "Vista in diretta",
"deleteInstallation": "Elimina installazione",
"errorOccured": "Si è verificato un errore",
"successfullyUpdated": "Aggiornamento riuscito",
"grantAccess": "Concedi accesso",
"UserswithDirectAccess": "Utenti con accesso diretto",
"UserswithInheritedAccess": "Utenti con accesso ereditato",
"noerrors": "Non ci sono errori",
"nowarnings": "Non ci sono avvisi",
"noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo",
"selectUsers": "Seleziona utenti",
"cancel": "Annulla",
"addNewFolder": "Aggiungi nuova cartella",
"addNewInstallation": "Aggiungi nuova installazione",
"deleteFolder": "Elimina cartella",
"grantAccessToFolders": "Concedi accesso alle cartelle",
"grantAccessToInstallations": "Concedi accesso alle installazioni",
"cannotloadloggingdata": "Impossibile caricare i dati di registro",
"grantedAccessToUsers": "Accesso concesso agli utenti: ",
"unableToGrantAccess": "Impossibile concedere l'accesso a: ",
"unableToLoadData": "Impossibile caricare i dati",
"unableToRevokeAccess": "Impossibile revocare l'accesso",
"revokedAccessFromUser": "Accesso revocato all'utente: ",
"alarms": "Allarmi",
"overview": "Panoramica",
"manage": "Gestione accessi",
"configuration": "Configurazione",
"installation_name_simple": "Nome installazione: ",
"installation_name": "Nome installazione",
"minimum_soc": "SoC minimo",
"calibration_charge_forced": "Carica di calibrazione forzata",
"grid_set_point": "Punto di riferimento rete",
"Installed_Power_DC1010": "Potenza installata DC1010",
"Maximum_Discharge_Power": "Potenza massima di scarica",
"Number_of_Batteries": "Numero di batterie",
"24_hours": "24 ore",
"lastweek": "Settimana scorsa",
"lastmonth": "Mese scorso",
"apply_changes": "Applica modifiche",
"delete_user": "Elimina utente",
"battery_temperature": "Temperatura batteria",
"pv_production": "Produzione fotovoltaica",
"grid_power": "Potenza di rete",
"battery_power": "Potenza batteria",
"dc_voltage": "Tensione bus DC",
"battery_soc": "Stato di carica (SOC)",
"Show Errors": "Mostra errori",
"Show Warnings": "Mostra avvisi",
"lastSeen": "Ultima visualizzazione",
"reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali",
"weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia solare utilizzata",
"solarStayedHome": "della tua energia solare è rimasta a casa",
"estMoneySaved": "Risparmio stimato",
"atCHFRate": "a 0,27 CHF/kWh media",
"solarCoverage": "Copertura solare",
"fromSolarSub": "del consumo da fonte solare",
"batteryEfficiency": "Efficienza della batteria",
"batteryEffSub": "energia in uscita vs energia in entrata",
"weeklySummary": "Riepilogo settimanale",
"metric": "Metrica",
"thisWeek": "Questa settimana",
"change": "Variazione",
"pvProduction": "Produzione FV",
"consumption": "Consumo",
"gridImport": "Importazione rete",
"gridExport": "Esportazione rete",
"batteryInOut": "Batteria Entrata / Uscita",
"dailyBreakdown": "Ripartizione giornaliera",
"prevWeek": "(settimana precedente)",
"sendReport": "Invia rapporto",
"generatingReport": "Generazione del rapporto settimanale...",
"reportSentTo": "Rapporto inviato a {email}",
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
"ok": "Ok",
"grantedAccessToUser": "Accesso concesso all'utente {name}",
"proceed": "Procedi",
"firmwareUpdating": "Il firmware è in fase di aggiornamento. Attendere prego...",
"confirmFirmwareUpdate": "Vuoi davvero aggiornare il firmware?",
"batteryServiceStopWarning": "Questa azione richiede l'interruzione del servizio batteria per circa 10-15 minuti.",
"downloadingBatteryLog": "Il registro della batteria è in fase di download. Verrà salvato nella cartella Download. Attendere prego...",
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
"noReportData": "Nessun dato del rapporto trovato."
}

View File

@ -141,10 +141,13 @@ function HeaderMenu(props: HeaderButtonsProps) {
English
</MenuItem>
<MenuItem value="de" onClick={() => handleLanguageSelect('de')}>
German
Deutsch
</MenuItem>
<MenuItem value="fr" onClick={() => handleLanguageSelect('fr')}>
French
Français
</MenuItem>
<MenuItem value="it" onClick={() => handleLanguageSelect('it')}>
Italiano
</MenuItem>
</Menu>
</div>