Compare commits
25 Commits
662810db98
...
69148410f2
| Author | SHA1 | Date |
|---|---|---|
|
|
69148410f2 | |
|
|
cb61c2bd42 | |
|
|
b4a296fd8a | |
|
|
591e273bc7 | |
|
|
1306ae6b9f | |
|
|
27b84a0d46 | |
|
|
ec830b5800 | |
|
|
a1911325ad | |
|
|
ac034b9983 | |
|
|
6cf14e3483 | |
|
|
0ac22ecbe9 | |
|
|
f7ee347fc5 | |
|
|
7f972d13c3 | |
|
|
f381f034d3 | |
|
|
8cd602c5cd | |
|
|
57ee8be520 | |
|
|
98abd68366 | |
|
|
c102ab3335 | |
|
|
401d82ea7a | |
|
|
2e52b9ee15 | |
|
|
66803a2b34 | |
|
|
37380e581f | |
|
|
5359ebba70 | |
|
|
f8b9428ce4 | |
|
|
9cee5398d4 |
|
|
@ -753,6 +753,16 @@ public class Controller : ControllerBase
|
||||||
return installation.HideParentIfUserHasNoAccessToParent(session!.User);
|
return installation.HideParentIfUserHasNoAccessToParent(session!.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetNetworkProviders))]
|
||||||
|
public ActionResult<IReadOnlyList<string>> GetNetworkProviders(Token authToken)
|
||||||
|
{
|
||||||
|
var session = Db.GetSession(authToken);
|
||||||
|
if (session is null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(NetworkProviderService.GetProviders());
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost(nameof(AcknowledgeError))]
|
[HttpPost(nameof(AcknowledgeError))]
|
||||||
public ActionResult AcknowledgeError(Int64 id, Token authToken)
|
public ActionResult AcknowledgeError(Int64 id, Token authToken)
|
||||||
{
|
{
|
||||||
|
|
@ -912,12 +922,13 @@ public class Controller : ControllerBase
|
||||||
// ── Weekly Performance Report ──────────────────────────────────────
|
// ── Weekly Performance Report ──────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a weekly performance report from an Excel file in tmp_report/{installationId}.xlsx
|
/// Returns a weekly performance report. Serves from cache if available;
|
||||||
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
|
/// generates fresh on first request or when forceRegenerate is true.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet(nameof(GetWeeklyReport))]
|
[HttpGet(nameof(GetWeeklyReport))]
|
||||||
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
|
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
|
||||||
Int64 installationId, Token authToken, String? language = null, String? weekStart = null)
|
Int64 installationId, Token authToken, String? language = null,
|
||||||
|
String? weekStart = null, Boolean forceRegenerate = false)
|
||||||
{
|
{
|
||||||
var user = Db.GetSession(authToken)?.User;
|
var user = Db.GetSession(authToken)?.User;
|
||||||
if (user == null)
|
if (user == null)
|
||||||
|
|
@ -939,8 +950,41 @@ public class Controller : ControllerBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var lang = language ?? user.Language ?? "en";
|
var lang = language ?? user.Language ?? "en";
|
||||||
|
|
||||||
|
// Compute target week dates for cache lookup
|
||||||
|
DateOnly periodStart, periodEnd;
|
||||||
|
if (weekStartDate.HasValue)
|
||||||
|
{
|
||||||
|
periodStart = weekStartDate.Value;
|
||||||
|
periodEnd = weekStartDate.Value.AddDays(6);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
|
||||||
|
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Cache-first: check if a cached report exists for this week
|
||||||
|
if (!forceRegenerate)
|
||||||
|
{
|
||||||
|
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
|
||||||
|
if (cachedResponse != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}");
|
||||||
|
return Ok(cachedResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss or forceRegenerate: generate fresh
|
||||||
|
Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}");
|
||||||
var report = await WeeklyReportService.GenerateReportAsync(
|
var report = await WeeklyReportService.GenerateReportAsync(
|
||||||
installationId, installation.InstallationName, lang, weekStartDate);
|
installationId, installation.Name, lang, weekStartDate);
|
||||||
|
|
||||||
// Persist weekly summary and seed AiInsightCache for this language
|
// Persist weekly summary and seed AiInsightCache for this language
|
||||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||||
|
|
@ -971,8 +1015,8 @@ public class Controller : ControllerBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var lang = user.Language ?? "en";
|
var lang = user.Language ?? "en";
|
||||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
|
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
|
||||||
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang);
|
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang, user.Name);
|
||||||
return Ok(new { message = $"Report sent to {emailAddress}" });
|
return Ok(new { message = $"Report sent to {emailAddress}" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -1191,6 +1235,153 @@ public class Controller : ControllerBase
|
||||||
return Ok(new { count = records.Count, records });
|
return Ok(new { count = records.Count, records });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetHourlyRecords))]
|
||||||
|
public ActionResult<List<HourlyEnergyRecord>> GetHourlyRecords(
|
||||||
|
Int64 installationId, String from, String to, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user == null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null || !user.HasAccessTo(installation))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
|
||||||
|
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
|
||||||
|
return BadRequest("from and to must be in yyyy-MM-dd format.");
|
||||||
|
|
||||||
|
var records = Db.GetHourlyRecords(installationId, fromDate, toDate);
|
||||||
|
return Ok(new { count = records.Count, records });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns daily + hourly records for a date range.
|
||||||
|
/// Fallback chain: DB → JSON (local + S3) → xlsx. Caches to DB on first parse.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet(nameof(GetDailyDetailRecords))]
|
||||||
|
public ActionResult GetDailyDetailRecords(
|
||||||
|
Int64 installationId, String from, String to, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user == null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null || !user.HasAccessTo(installation))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
|
||||||
|
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
|
||||||
|
return BadRequest("from and to must be in yyyy-MM-dd format.");
|
||||||
|
|
||||||
|
// 1. Try DB
|
||||||
|
var dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
|
||||||
|
var hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
|
||||||
|
|
||||||
|
if (dailyRecords.Count > 0 && hourlyRecords.Count > 0)
|
||||||
|
return Ok(FormatResult(dailyRecords, hourlyRecords));
|
||||||
|
|
||||||
|
// 2. Fallback: try JSON (local files + S3)
|
||||||
|
TryIngestFromJson(installationId, installation, fromDate, toDate);
|
||||||
|
dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
|
||||||
|
hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
|
||||||
|
|
||||||
|
if (dailyRecords.Count > 0 && hourlyRecords.Count > 0)
|
||||||
|
return Ok(FormatResult(dailyRecords, hourlyRecords));
|
||||||
|
|
||||||
|
// 3. Fallback: parse xlsx + cache to DB
|
||||||
|
TryIngestFromXlsx(installationId, fromDate, toDate);
|
||||||
|
dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
|
||||||
|
hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
|
||||||
|
|
||||||
|
return Ok(FormatResult(dailyRecords, hourlyRecords));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object FormatResult(
|
||||||
|
List<DailyEnergyRecord> daily, List<HourlyEnergyRecord> hourly) => new
|
||||||
|
{
|
||||||
|
dailyRecords = new { count = daily.Count, records = daily },
|
||||||
|
hourlyRecords = new { count = hourly.Count, records = hourly },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void TryIngestFromJson(
|
||||||
|
Int64 installationId, Installation installation,
|
||||||
|
DateOnly fromDate, DateOnly toDate)
|
||||||
|
{
|
||||||
|
var jsonDir = Path.Combine(
|
||||||
|
Environment.CurrentDirectory, "tmp_report", "aggregated", installationId.ToString());
|
||||||
|
|
||||||
|
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
var isoDate = date.ToString("yyyy-MM-dd");
|
||||||
|
var fileName = AggregatedJsonParser.ToJsonFileName(date);
|
||||||
|
|
||||||
|
// Try local file first
|
||||||
|
var localPath = Path.Combine(jsonDir, fileName);
|
||||||
|
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
|
||||||
|
|
||||||
|
// Try S3 if no local file
|
||||||
|
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (content is null) continue;
|
||||||
|
|
||||||
|
DailyIngestionService.IngestJsonContent(installationId, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryIngestFromXlsx(
|
||||||
|
Int64 installationId, DateOnly fromDate, DateOnly toDate)
|
||||||
|
{
|
||||||
|
var xlsxFiles = WeeklyReportService.GetRelevantXlsxFiles(installationId, fromDate, toDate);
|
||||||
|
if (xlsxFiles.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var xlsxPath in xlsxFiles)
|
||||||
|
{
|
||||||
|
foreach (var day in ExcelDataParser.Parse(xlsxPath))
|
||||||
|
{
|
||||||
|
if (Db.DailyRecordExists(installationId, day.Date))
|
||||||
|
continue;
|
||||||
|
Db.Create(new DailyEnergyRecord
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
Date = day.Date,
|
||||||
|
PvProduction = day.PvProduction,
|
||||||
|
LoadConsumption = day.LoadConsumption,
|
||||||
|
GridImport = day.GridImport,
|
||||||
|
GridExport = day.GridExport,
|
||||||
|
BatteryCharged = day.BatteryCharged,
|
||||||
|
BatteryDischarged = day.BatteryDischarged,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var hour in ExcelDataParser.ParseHourly(xlsxPath))
|
||||||
|
{
|
||||||
|
var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}";
|
||||||
|
if (Db.HourlyRecordExists(installationId, dateHour))
|
||||||
|
continue;
|
||||||
|
Db.Create(new HourlyEnergyRecord
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
Date = hour.DateTime.ToString("yyyy-MM-dd"),
|
||||||
|
Hour = hour.Hour,
|
||||||
|
DateHour = dateHour,
|
||||||
|
DayOfWeek = hour.DayOfWeek,
|
||||||
|
IsWeekend = hour.IsWeekend,
|
||||||
|
PvKwh = hour.PvKwh,
|
||||||
|
LoadKwh = hour.LoadKwh,
|
||||||
|
GridImportKwh = hour.GridImportKwh,
|
||||||
|
BatteryChargedKwh = hour.BatteryChargedKwh,
|
||||||
|
BatteryDischargedKwh = hour.BatteryDischargedKwh,
|
||||||
|
BattSoC = hour.BattSoC,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
|
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
|
||||||
/// Safe to use during testing — only removes daily records, not report summaries.
|
/// Safe to use during testing — only removes daily records, not report summaries.
|
||||||
|
|
@ -1247,7 +1438,7 @@ public class Controller : ControllerBase
|
||||||
{
|
{
|
||||||
var lang = user.Language ?? "en";
|
var lang = user.Language ?? "en";
|
||||||
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
|
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
|
||||||
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.Name, emailAddress, lang, user.Name);
|
||||||
return Ok(new { message = $"Monthly report sent to {emailAddress}" });
|
return Ok(new { message = $"Monthly report sent to {emailAddress}" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -1276,7 +1467,7 @@ public class Controller : ControllerBase
|
||||||
{
|
{
|
||||||
var lang = user.Language ?? "en";
|
var lang = user.Language ?? "en";
|
||||||
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
|
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
|
||||||
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.Name, emailAddress, lang, user.Name);
|
||||||
return Ok(new { message = $"Yearly report sent to {emailAddress}" });
|
return Ok(new { message = $"Yearly report sent to {emailAddress}" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -1746,7 +1937,171 @@ public class Controller : ControllerBase
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// TICKET ENDPOINTS (admin-only)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetAllTickets))]
|
||||||
|
public ActionResult<IEnumerable<Ticket>> GetAllTickets(Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
return Db.GetAllTickets();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketsForInstallation))]
|
||||||
|
public ActionResult<IEnumerable<Ticket>> GetTicketsForInstallation(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null) return NotFound();
|
||||||
|
|
||||||
|
return Db.GetTicketsForInstallation(installationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketById))]
|
||||||
|
public ActionResult<Ticket> GetTicketById(Int64 id, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(id);
|
||||||
|
return ticket is null ? NotFound() : ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost(nameof(CreateTicket))]
|
||||||
|
public ActionResult<Ticket> CreateTicket([FromBody] Ticket ticket, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
ticket.CreatedByUserId = user.Id;
|
||||||
|
ticket.CreatedAt = DateTime.UtcNow;
|
||||||
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
|
ticket.Status = (Int32)TicketStatus.Open;
|
||||||
|
|
||||||
|
if (!Db.Create(ticket)) return StatusCode(500, "Failed to create ticket.");
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = ticket.Id,
|
||||||
|
EventType = (Int32)TimelineEventType.Created,
|
||||||
|
Description = $"Ticket created by {user.Name}.",
|
||||||
|
ActorType = (Int32)TimelineActorType.Human,
|
||||||
|
ActorId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget AI diagnosis
|
||||||
|
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning();
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut(nameof(UpdateTicket))]
|
||||||
|
public ActionResult<Ticket> UpdateTicket([FromBody] Ticket ticket, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var existing = Db.GetTicketById(ticket.Id);
|
||||||
|
if (existing is null) return NotFound();
|
||||||
|
|
||||||
|
ticket.CreatedAt = existing.CreatedAt;
|
||||||
|
ticket.CreatedByUserId = existing.CreatedByUserId;
|
||||||
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (ticket.Status != existing.Status)
|
||||||
|
{
|
||||||
|
if (ticket.Status == (Int32)TicketStatus.Resolved)
|
||||||
|
ticket.ResolvedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = ticket.Id,
|
||||||
|
EventType = (Int32)TimelineEventType.StatusChanged,
|
||||||
|
Description = $"Status changed to {(TicketStatus)ticket.Status}.",
|
||||||
|
ActorType = (Int32)TimelineActorType.Human,
|
||||||
|
ActorId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete(nameof(DeleteTicket))]
|
||||||
|
public ActionResult DeleteTicket(Int64 id, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(id);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
|
||||||
|
return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketComments))]
|
||||||
|
public ActionResult<IEnumerable<TicketComment>> GetTicketComments(Int64 ticketId, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
return Db.GetCommentsForTicket(ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost(nameof(AddTicketComment))]
|
||||||
|
public ActionResult<TicketComment> AddTicketComment([FromBody] TicketComment comment, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(comment.TicketId);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
|
||||||
|
comment.AuthorType = (Int32)CommentAuthorType.Human;
|
||||||
|
comment.AuthorId = user.Id;
|
||||||
|
comment.CreatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (!Db.Create(comment)) return StatusCode(500, "Failed to add comment.");
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = comment.TicketId,
|
||||||
|
EventType = (Int32)TimelineEventType.CommentAdded,
|
||||||
|
Description = $"Comment added by {user.Name}.",
|
||||||
|
ActorType = (Int32)TimelineActorType.Human,
|
||||||
|
ActorId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
|
Db.Update(ticket);
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketDetail))]
|
||||||
|
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(id);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ticket,
|
||||||
|
comments = Db.GetCommentsForTicket(id),
|
||||||
|
diagnosis = Db.GetDiagnosisForTicket(id),
|
||||||
|
timeline = Db.GetTimelineForTicket(id)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,5 @@ public class Installation : TreeNode
|
||||||
public String OrderNumbers { get; set; }
|
public String OrderNumbers { get; set; }
|
||||||
public String VrmLink { get; set; } = "";
|
public String VrmLink { get; set; } = "";
|
||||||
public string Configuration { get; set; } = "";
|
public string Configuration { get; set; } = "";
|
||||||
|
public string NetworkProvider { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
@ -436,9 +436,9 @@ public static class ExoCmd
|
||||||
public static async Task<Boolean> SendConfig(this Installation installation, Configuration config)
|
public static async Task<Boolean> SendConfig(this Installation installation, Configuration config)
|
||||||
{
|
{
|
||||||
|
|
||||||
var maxRetransmissions = 4;
|
var maxRetransmissions = 6;
|
||||||
UdpClient udpClient = new UdpClient();
|
UdpClient udpClient = new UdpClient();
|
||||||
udpClient.Client.ReceiveTimeout = 2000;
|
udpClient.Client.ReceiveTimeout = 3000;
|
||||||
int port = 9000;
|
int port = 9000;
|
||||||
|
|
||||||
Console.WriteLine("Trying to reach installation with IP: " + installation.VpnIp);
|
Console.WriteLine("Trying to reach installation with IP: " + installation.VpnIp);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ public class WeeklyReportSummary
|
||||||
// AI insight for this week
|
// AI insight for this week
|
||||||
public String AiInsight { get; set; } = "";
|
public String AiInsight { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full serialized WeeklyReportResponse (with AiInsight cleared).
|
||||||
|
/// Used for cache-first serving — avoids regenerating numeric data + Mistral call.
|
||||||
|
/// </summary>
|
||||||
|
public String ResponseJson { get; set; } = "";
|
||||||
|
|
||||||
public String CreatedAt { get; set; } = "";
|
public String CreatedAt { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 }
|
||||||
|
public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 }
|
||||||
|
public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 }
|
||||||
|
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
|
||||||
|
|
||||||
|
public class Ticket
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
public String Subject { get; set; } = "";
|
||||||
|
public String Description { get; set; } = "";
|
||||||
|
|
||||||
|
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
|
||||||
|
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
|
||||||
|
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
|
||||||
|
public Int32 Source { get; set; } = (Int32)TicketSource.Manual;
|
||||||
|
|
||||||
|
[Indexed] public Int64 InstallationId { get; set; }
|
||||||
|
public Int64? AssigneeId { get; set; }
|
||||||
|
[Indexed] public Int64 CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
public String Tags { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? ResolvedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum DiagnosisStatus { Pending = 0, Analyzing = 1, Completed = 2, Failed = 3 }
|
||||||
|
public enum DiagnosisFeedback { Accepted = 0, Rejected = 1, Overridden = 2 }
|
||||||
|
|
||||||
|
public class TicketAiDiagnosis
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 TicketId { get; set; }
|
||||||
|
public Int32 Status { get; set; } = (Int32)DiagnosisStatus.Pending;
|
||||||
|
public String? RootCause { get; set; }
|
||||||
|
public Double? Confidence { get; set; }
|
||||||
|
public String? RecommendedActions { get; set; } // JSON array string
|
||||||
|
public String? SimilarTicketIds { get; set; } // comma-separated
|
||||||
|
public Int32? Feedback { get; set; } // null = no feedback yet
|
||||||
|
public String? OverrideText { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum CommentAuthorType { Human = 0, AiAgent = 1 }
|
||||||
|
|
||||||
|
public class TicketComment
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 TicketId { get; set; }
|
||||||
|
public Int32 AuthorType { get; set; } = (Int32)CommentAuthorType.Human;
|
||||||
|
public Int64? AuthorId { get; set; }
|
||||||
|
public String Body { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum TimelineEventType
|
||||||
|
{
|
||||||
|
Created = 0, StatusChanged = 1, Assigned = 2,
|
||||||
|
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }
|
||||||
|
|
||||||
|
public class TicketTimelineEvent
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 TicketId { get; set; }
|
||||||
|
public Int32 EventType { get; set; }
|
||||||
|
public String Description { get; set; } = "";
|
||||||
|
public Int32 ActorType { get; set; } = (Int32)TimelineActorType.System;
|
||||||
|
public Int64? ActorId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,12 @@ public static partial class Db
|
||||||
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
||||||
public static Boolean Create(AiInsightCache cache) => Insert(cache);
|
public static Boolean Create(AiInsightCache cache) => Insert(cache);
|
||||||
|
|
||||||
|
// Ticket system
|
||||||
|
public static Boolean Create(Ticket ticket) => Insert(ticket);
|
||||||
|
public static Boolean Create(TicketComment comment) => Insert(comment);
|
||||||
|
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||||
|
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||||
|
|
||||||
public static void HandleAction(UserAction newAction)
|
public static void HandleAction(UserAction newAction)
|
||||||
{
|
{
|
||||||
//Find the total number of actions for this installation
|
//Find the total number of actions for this installation
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ public static partial class Db
|
||||||
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
||||||
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
|
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
|
||||||
|
|
||||||
|
// Ticket system tables
|
||||||
|
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
|
||||||
|
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
|
||||||
|
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
||||||
|
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||||
|
|
||||||
|
|
||||||
public static void Init()
|
public static void Init()
|
||||||
{
|
{
|
||||||
|
|
@ -63,6 +69,12 @@ public static partial class Db
|
||||||
Connection.CreateTable<DailyEnergyRecord>();
|
Connection.CreateTable<DailyEnergyRecord>();
|
||||||
Connection.CreateTable<HourlyEnergyRecord>();
|
Connection.CreateTable<HourlyEnergyRecord>();
|
||||||
Connection.CreateTable<AiInsightCache>();
|
Connection.CreateTable<AiInsightCache>();
|
||||||
|
|
||||||
|
// Ticket system tables
|
||||||
|
Connection.CreateTable<Ticket>();
|
||||||
|
Connection.CreateTable<TicketComment>();
|
||||||
|
Connection.CreateTable<TicketAiDiagnosis>();
|
||||||
|
Connection.CreateTable<TicketTimelineEvent>();
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||||
|
|
@ -112,6 +124,12 @@ public static partial class Db
|
||||||
fileConnection.CreateTable<HourlyEnergyRecord>();
|
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||||
fileConnection.CreateTable<AiInsightCache>();
|
fileConnection.CreateTable<AiInsightCache>();
|
||||||
|
|
||||||
|
// Ticket system tables
|
||||||
|
fileConnection.CreateTable<Ticket>();
|
||||||
|
fileConnection.CreateTable<TicketComment>();
|
||||||
|
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||||
|
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
return fileConnection;
|
return fileConnection;
|
||||||
//return CopyDbToMemory(fileConnection);
|
//return CopyDbToMemory(fileConnection);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,17 @@ public static partial class Db
|
||||||
Warnings .Delete(w => w.InstallationId == installation.Id);
|
Warnings .Delete(w => w.InstallationId == installation.Id);
|
||||||
UserActions .Delete(a => a.InstallationId == installation.Id);
|
UserActions .Delete(a => a.InstallationId == installation.Id);
|
||||||
|
|
||||||
|
// Clean up tickets and their children for this installation
|
||||||
|
var ticketIds = Tickets.Where(t => t.InstallationId == installation.Id)
|
||||||
|
.Select(t => t.Id).ToList();
|
||||||
|
foreach (var tid in ticketIds)
|
||||||
|
{
|
||||||
|
TicketComments .Delete(c => c.TicketId == tid);
|
||||||
|
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
|
||||||
|
TicketTimelineEvents.Delete(e => e.TicketId == tid);
|
||||||
|
}
|
||||||
|
Tickets.Delete(t => t.InstallationId == installation.Id);
|
||||||
|
|
||||||
return Installations.Delete(i => i.Id == installation.Id) > 0;
|
return Installations.Delete(i => i.Id == installation.Id) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +210,21 @@ public static partial class Db
|
||||||
if (count > 0) Backup();
|
if (count > 0) Backup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Boolean Delete(Ticket ticket)
|
||||||
|
{
|
||||||
|
var deleteSuccess = RunTransaction(DeleteTicketAndChildren);
|
||||||
|
if (deleteSuccess) Backup();
|
||||||
|
return deleteSuccess;
|
||||||
|
|
||||||
|
Boolean DeleteTicketAndChildren()
|
||||||
|
{
|
||||||
|
TicketComments .Delete(c => c.TicketId == ticket.Id);
|
||||||
|
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
|
||||||
|
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
|
||||||
|
return Tickets.Delete(t => t.Id == ticket.Id) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
||||||
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,18 @@ public static partial class Db
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a cached weekly report whose period overlaps with the given date range.
|
||||||
|
/// Uses overlap logic (not exact match) because PeriodStart may be offset
|
||||||
|
/// if the first day of the week has no data.
|
||||||
|
/// </summary>
|
||||||
|
public static WeeklyReportSummary? GetWeeklyReportForWeek(Int64 installationId, String periodStart, String periodEnd)
|
||||||
|
=> WeeklyReports
|
||||||
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
.ToList()
|
||||||
|
.FirstOrDefault(r => String.Compare(r.PeriodStart, periodEnd, StringComparison.Ordinal) <= 0
|
||||||
|
&& String.Compare(r.PeriodEnd, periodStart, StringComparison.Ordinal) >= 0);
|
||||||
|
|
||||||
public static List<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
|
public static List<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
|
||||||
=> MonthlyReports
|
=> MonthlyReports
|
||||||
.Where(r => r.InstallationId == installationId)
|
.Where(r => r.InstallationId == installationId)
|
||||||
|
|
@ -104,13 +116,9 @@ public static partial class Db
|
||||||
{
|
{
|
||||||
var fromStr = from.ToString("yyyy-MM-dd");
|
var fromStr = from.ToString("yyyy-MM-dd");
|
||||||
var toStr = to.ToString("yyyy-MM-dd");
|
var toStr = to.ToString("yyyy-MM-dd");
|
||||||
return DailyRecords
|
return Connection.Query<DailyEnergyRecord>(
|
||||||
.Where(r => r.InstallationId == installationId)
|
"SELECT * FROM DailyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date",
|
||||||
.ToList()
|
installationId, fromStr, toStr);
|
||||||
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
|
|
||||||
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
|
|
||||||
.OrderBy(r => r.Date)
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -129,13 +137,9 @@ public static partial class Db
|
||||||
{
|
{
|
||||||
var fromStr = from.ToString("yyyy-MM-dd");
|
var fromStr = from.ToString("yyyy-MM-dd");
|
||||||
var toStr = to.ToString("yyyy-MM-dd");
|
var toStr = to.ToString("yyyy-MM-dd");
|
||||||
return HourlyRecords
|
return Connection.Query<HourlyEnergyRecord>(
|
||||||
.Where(r => r.InstallationId == installationId)
|
"SELECT * FROM HourlyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date, Hour",
|
||||||
.ToList()
|
installationId, fromStr, toStr);
|
||||||
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
|
|
||||||
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
|
|
||||||
.OrderBy(r => r.Date).ThenBy(r => r.Hour)
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -156,4 +160,33 @@ public static partial class Db
|
||||||
&& c.ReportId == reportId
|
&& c.ReportId == reportId
|
||||||
&& c.Language == language)
|
&& c.Language == language)
|
||||||
?.InsightText;
|
?.InsightText;
|
||||||
|
|
||||||
|
// ── Ticket Queries ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static Ticket? GetTicketById(Int64 id)
|
||||||
|
=> Tickets.FirstOrDefault(t => t.Id == id);
|
||||||
|
|
||||||
|
public static List<Ticket> GetAllTickets()
|
||||||
|
=> Tickets.OrderByDescending(t => t.UpdatedAt).ToList();
|
||||||
|
|
||||||
|
public static List<Ticket> GetTicketsForInstallation(Int64 installationId)
|
||||||
|
=> Tickets
|
||||||
|
.Where(t => t.InstallationId == installationId)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static List<TicketComment> GetCommentsForTicket(Int64 ticketId)
|
||||||
|
=> TicketComments
|
||||||
|
.Where(c => c.TicketId == ticketId)
|
||||||
|
.OrderBy(c => c.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static TicketAiDiagnosis? GetDiagnosisForTicket(Int64 ticketId)
|
||||||
|
=> TicketAiDiagnoses.FirstOrDefault(d => d.TicketId == ticketId);
|
||||||
|
|
||||||
|
public static List<TicketTimelineEvent> GetTimelineForTicket(Int64 ticketId)
|
||||||
|
=> TicketTimelineEvents
|
||||||
|
.Where(e => e.TicketId == ticketId)
|
||||||
|
.OrderBy(e => e.CreatedAt)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
@ -56,4 +56,20 @@ public static partial class Db
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates ONLY the Status column for an installation.
|
||||||
|
/// This avoids a full-row overwrite that can race with TestingMode changes.
|
||||||
|
/// </summary>
|
||||||
|
public static Boolean UpdateInstallationStatus(Int64 installationId, int status)
|
||||||
|
{
|
||||||
|
var rows = Connection.Execute(
|
||||||
|
"UPDATE Installation SET Status = ? WHERE Id = ?",
|
||||||
|
status, installationId);
|
||||||
|
if (rows > 0) Backup();
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket system
|
||||||
|
public static Boolean Update(Ticket ticket) => Update(obj: ticket);
|
||||||
|
public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis);
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ public static class Program
|
||||||
Db.Init();
|
Db.Init();
|
||||||
LoadEnvFile();
|
LoadEnvFile();
|
||||||
DiagnosticService.Initialize();
|
DiagnosticService.Initialize();
|
||||||
|
TicketDiagnosticService.Initialize();
|
||||||
|
NetworkProviderService.Initialize();
|
||||||
AlarmReviewService.StartDailyScheduler();
|
AlarmReviewService.StartDailyScheduler();
|
||||||
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
||||||
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||||
|
using InnovEnergy.Lib.S3Utils;
|
||||||
|
using InnovEnergy.Lib.S3Utils.DataTypes;
|
||||||
|
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses NDJSON aggregated data files generated by SodistoreHome devices.
|
||||||
|
/// Each file (DDMMYYYY.json) contains one JSON object per line:
|
||||||
|
/// - Type "Hourly": per-hour kWh values (already computed, no diffing needed)
|
||||||
|
/// - Type "Daily": daily totals
|
||||||
|
/// </summary>
|
||||||
|
public static class AggregatedJsonParser
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<DailyEnergyData> ParseDaily(String ndjsonContent)
|
||||||
|
{
|
||||||
|
var dailyByDate = new SortedDictionary<String, DailyEnergyData>();
|
||||||
|
|
||||||
|
foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (!line.Contains("\"Type\":\"Daily\""))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = JsonSerializer.Deserialize<DailyJsonDto>(line, JsonOpts);
|
||||||
|
if (raw is null) continue;
|
||||||
|
|
||||||
|
var date = raw.Timestamp.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
dailyByDate[date] = new DailyEnergyData
|
||||||
|
{
|
||||||
|
Date = date,
|
||||||
|
PvProduction = Math.Round(raw.DailySelfGeneratedElectricity, 4),
|
||||||
|
GridImport = Math.Round(raw.DailyElectricityPurchased, 4),
|
||||||
|
GridExport = Math.Round(raw.DailyElectricityFed, 4),
|
||||||
|
BatteryCharged = Math.Round(raw.BatteryDailyChargeEnergy, 4),
|
||||||
|
BatteryDischarged = Math.Round(raw.BatteryDailyDischargeEnergy, 4),
|
||||||
|
LoadConsumption = Math.Round(raw.DailyLoadPowerConsumption, 4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[AggregatedJsonParser] Skipping daily line: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[AggregatedJsonParser] Parsed {dailyByDate.Count} daily record(s)");
|
||||||
|
return dailyByDate.Values.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<HourlyEnergyData> ParseHourly(String ndjsonContent)
|
||||||
|
{
|
||||||
|
var result = new List<HourlyEnergyData>();
|
||||||
|
|
||||||
|
foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (!line.Contains("\"Type\":\"Hourly\""))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = JsonSerializer.Deserialize<HourlyJsonDto>(line, JsonOpts);
|
||||||
|
if (raw is null) continue;
|
||||||
|
|
||||||
|
var dt = new DateTime(
|
||||||
|
raw.Timestamp.Year, raw.Timestamp.Month, raw.Timestamp.Day,
|
||||||
|
raw.Timestamp.Hour, 0, 0);
|
||||||
|
|
||||||
|
result.Add(new HourlyEnergyData
|
||||||
|
{
|
||||||
|
DateTime = dt,
|
||||||
|
Hour = dt.Hour,
|
||||||
|
DayOfWeek = dt.DayOfWeek.ToString(),
|
||||||
|
IsWeekend = dt.DayOfWeek is System.DayOfWeek.Saturday or System.DayOfWeek.Sunday,
|
||||||
|
PvKwh = Math.Round(raw.SelfGeneratedElectricity, 4),
|
||||||
|
GridImportKwh = Math.Round(raw.ElectricityPurchased, 4),
|
||||||
|
BatteryChargedKwh = Math.Round(raw.BatteryChargeEnergy, 4),
|
||||||
|
BatteryDischargedKwh = Math.Round(raw.BatteryDischargeEnergy, 4),
|
||||||
|
LoadKwh = Math.Round(raw.LoadPowerConsumption, 4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[AggregatedJsonParser] Skipping hourly line: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[AggregatedJsonParser] Parsed {result.Count} hourly record(s)");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts ISO date "yyyy-MM-dd" to device filename format "ddMMyyyy".
|
||||||
|
/// </summary>
|
||||||
|
public static String ToJsonFileName(String isoDate)
|
||||||
|
{
|
||||||
|
var d = DateOnly.ParseExact(isoDate, "yyyy-MM-dd");
|
||||||
|
return d.ToString("ddMMyyyy") + ".json";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String ToJsonFileName(DateOnly date) => date.ToString("ddMMyyyy") + ".json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to read an aggregated JSON file from the installation's S3 bucket.
|
||||||
|
/// S3 key: DDMMYYYY.json (directly in bucket root).
|
||||||
|
/// Returns file content or null if not found / error.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<String?> TryReadFromS3(Installation installation, String isoDate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileName = ToJsonFileName(isoDate);
|
||||||
|
var region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", ExoCmd.S3Credentials!);
|
||||||
|
var bucket = region.Bucket(installation.BucketName());
|
||||||
|
var s3Url = bucket.Path(fileName);
|
||||||
|
|
||||||
|
return await s3Url.GetObjectAsString();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[AggregatedJsonParser] S3 read failed for {isoDate}: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON DTOs ---
|
||||||
|
|
||||||
|
private sealed class HourlyJsonDto
|
||||||
|
{
|
||||||
|
public String Type { get; set; } = "";
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public Double SelfGeneratedElectricity { get; set; }
|
||||||
|
public Double ElectricityPurchased { get; set; }
|
||||||
|
public Double ElectricityFed { get; set; }
|
||||||
|
public Double BatteryChargeEnergy { get; set; }
|
||||||
|
public Double BatteryDischargeEnergy { get; set; }
|
||||||
|
public Double LoadPowerConsumption { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DailyJsonDto
|
||||||
|
{
|
||||||
|
public String Type { get; set; } = "";
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public Double DailySelfGeneratedElectricity { get; set; }
|
||||||
|
public Double DailyElectricityPurchased { get; set; }
|
||||||
|
public Double DailyElectricityFed { get; set; }
|
||||||
|
public Double BatteryDailyChargeEnergy { get; set; }
|
||||||
|
public Double BatteryDailyDischargeEnergy { get; set; }
|
||||||
|
public Double DailyLoadPowerConsumption { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,8 @@ using InnovEnergy.App.Backend.DataTypes;
|
||||||
namespace InnovEnergy.App.Backend.Services;
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ingests daily energy totals from xlsx files into the DailyEnergyRecord SQLite table.
|
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
|
||||||
/// This is the source-of-truth population step for the report pipeline.
|
/// Data source priority: JSON (local) → JSON (S3) → xlsx fallback.
|
||||||
///
|
|
||||||
/// Current data source: xlsx files placed in tmp_report/{installationId}.xlsx
|
|
||||||
/// Future data source: S3 raw records (replace ExcelDataParser call with S3DailyExtractor)
|
|
||||||
///
|
|
||||||
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
|
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
|
||||||
/// IngestDailyData API endpoint.
|
/// IngestDailyData API endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -18,6 +14,9 @@ public static class DailyIngestionService
|
||||||
private static readonly String TmpReportDir =
|
private static readonly String TmpReportDir =
|
||||||
Environment.CurrentDirectory + "/tmp_report/";
|
Environment.CurrentDirectory + "/tmp_report/";
|
||||||
|
|
||||||
|
private static readonly String JsonAggregatedDir =
|
||||||
|
Environment.CurrentDirectory + "/tmp_report/aggregated/";
|
||||||
|
|
||||||
private static Timer? _dailyTimer;
|
private static Timer? _dailyTimer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -73,11 +72,119 @@ public static class DailyIngestionService
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses all xlsx files matching {installationId}*.xlsx in tmp_report/ and stores
|
/// Ingests data for one installation. Tries JSON (local + S3) and xlsx.
|
||||||
/// any new days as DailyEnergyRecord rows. Supports multiple time-ranged files per
|
/// Both sources are tried — idempotency checks prevent duplicates.
|
||||||
/// installation (e.g. 123_0203_0803.xlsx, 123_0901_1231.xlsx). Idempotent.
|
/// JSON provides recent data; xlsx provides historical data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task IngestInstallationAsync(Int64 installationId)
|
public static async Task IngestInstallationAsync(Int64 installationId)
|
||||||
|
{
|
||||||
|
await TryIngestFromJson(installationId);
|
||||||
|
IngestFromXlsx(installationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
|
||||||
|
{
|
||||||
|
var newDaily = 0;
|
||||||
|
var newHourly = 0;
|
||||||
|
var jsonDir = Path.Combine(JsonAggregatedDir, installationId.ToString());
|
||||||
|
|
||||||
|
// Collect JSON content from local files
|
||||||
|
var jsonFiles = Directory.Exists(jsonDir)
|
||||||
|
? Directory.GetFiles(jsonDir, "*.json")
|
||||||
|
: Array.Empty<String>();
|
||||||
|
|
||||||
|
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(jsonPath);
|
||||||
|
var (d, h) = IngestJsonContent(installationId, content);
|
||||||
|
newDaily += d;
|
||||||
|
newHourly += h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try S3 for recent days (yesterday + today) if no local files found
|
||||||
|
if (jsonFiles.Length == 0)
|
||||||
|
{
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is not null)
|
||||||
|
{
|
||||||
|
for (var daysBack = 0; daysBack <= 1; daysBack++)
|
||||||
|
{
|
||||||
|
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
|
||||||
|
var isoDate = date.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
if (Db.DailyRecordExists(installationId, isoDate))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
|
||||||
|
if (content is null) continue;
|
||||||
|
|
||||||
|
var (d, h) = IngestJsonContent(installationId, content);
|
||||||
|
newDaily += d;
|
||||||
|
newHourly += h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDaily > 0 || newHourly > 0)
|
||||||
|
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
||||||
|
|
||||||
|
return newDaily > 0 || newHourly > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (Int32 daily, Int32 hourly) IngestJsonContent(Int64 installationId, String content)
|
||||||
|
{
|
||||||
|
var newDaily = 0;
|
||||||
|
var newHourly = 0;
|
||||||
|
|
||||||
|
foreach (var day in AggregatedJsonParser.ParseDaily(content))
|
||||||
|
{
|
||||||
|
if (Db.DailyRecordExists(installationId, day.Date))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Db.Create(new DailyEnergyRecord
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
Date = day.Date,
|
||||||
|
PvProduction = day.PvProduction,
|
||||||
|
LoadConsumption = day.LoadConsumption,
|
||||||
|
GridImport = day.GridImport,
|
||||||
|
GridExport = day.GridExport,
|
||||||
|
BatteryCharged = day.BatteryCharged,
|
||||||
|
BatteryDischarged = day.BatteryDischarged,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
});
|
||||||
|
newDaily++;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var hour in AggregatedJsonParser.ParseHourly(content))
|
||||||
|
{
|
||||||
|
var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}";
|
||||||
|
if (Db.HourlyRecordExists(installationId, dateHour))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Db.Create(new HourlyEnergyRecord
|
||||||
|
{
|
||||||
|
InstallationId = installationId,
|
||||||
|
Date = hour.DateTime.ToString("yyyy-MM-dd"),
|
||||||
|
Hour = hour.Hour,
|
||||||
|
DateHour = dateHour,
|
||||||
|
DayOfWeek = hour.DayOfWeek,
|
||||||
|
IsWeekend = hour.IsWeekend,
|
||||||
|
PvKwh = hour.PvKwh,
|
||||||
|
LoadKwh = hour.LoadKwh,
|
||||||
|
GridImportKwh = hour.GridImportKwh,
|
||||||
|
BatteryChargedKwh = hour.BatteryChargedKwh,
|
||||||
|
BatteryDischargedKwh = hour.BatteryDischargedKwh,
|
||||||
|
BattSoC = 0,
|
||||||
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
|
});
|
||||||
|
newHourly++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (newDaily, newHourly);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void IngestFromXlsx(Int64 installationId)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(TmpReportDir))
|
if (!Directory.Exists(TmpReportDir))
|
||||||
{
|
{
|
||||||
|
|
@ -98,12 +205,8 @@ public static class DailyIngestionService
|
||||||
|
|
||||||
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
|
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
|
||||||
{
|
{
|
||||||
// Ingest daily records
|
|
||||||
List<DailyEnergyData> days;
|
List<DailyEnergyData> days;
|
||||||
try
|
try { days = ExcelDataParser.Parse(xlsxPath); }
|
||||||
{
|
|
||||||
days = ExcelDataParser.Parse(xlsxPath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||||
|
|
@ -132,12 +235,8 @@ public static class DailyIngestionService
|
||||||
newDailyCount++;
|
newDailyCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingest hourly records
|
|
||||||
List<HourlyEnergyData> hours;
|
List<HourlyEnergyData> hours;
|
||||||
try
|
try { hours = ExcelDataParser.ParseHourly(xlsxPath); }
|
||||||
{
|
|
||||||
hours = ExcelDataParser.ParseHourly(xlsxPath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||||
|
|
@ -170,7 +269,6 @@ public static class DailyIngestionService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
|
Console.WriteLine($"[DailyIngestion] Installation {installationId} (xlsx): {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
using Flurl.Http;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches and caches the list of Swiss electricity network providers (Netzbetreiber)
|
||||||
|
/// from the ELCOM/LINDAS SPARQL endpoint. Refreshes every 24 hours.
|
||||||
|
/// </summary>
|
||||||
|
public static class NetworkProviderService
|
||||||
|
{
|
||||||
|
private static IReadOnlyList<string> _providers = Array.Empty<string>();
|
||||||
|
private static Timer? _refreshTimer;
|
||||||
|
|
||||||
|
private const string SparqlEndpoint = "https://ld.admin.ch/query";
|
||||||
|
|
||||||
|
private const string SparqlQuery = @"
|
||||||
|
PREFIX schema: <http://schema.org/>
|
||||||
|
SELECT DISTINCT ?name
|
||||||
|
FROM <https://lindas.admin.ch/elcom/electricityprice>
|
||||||
|
WHERE {
|
||||||
|
?operator a schema:Organization ;
|
||||||
|
schema:name ?name .
|
||||||
|
}
|
||||||
|
ORDER BY ?name";
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
// Fire-and-forget initial load
|
||||||
|
Task.Run(RefreshAsync);
|
||||||
|
|
||||||
|
// Refresh every 24 hours
|
||||||
|
_refreshTimer = new Timer(
|
||||||
|
_ => Task.Run(RefreshAsync),
|
||||||
|
null,
|
||||||
|
TimeSpan.FromHours(24),
|
||||||
|
TimeSpan.FromHours(24)
|
||||||
|
);
|
||||||
|
|
||||||
|
Console.WriteLine("[NetworkProviderService] initialised.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> GetProviders() => _providers;
|
||||||
|
|
||||||
|
private static async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await SparqlEndpoint
|
||||||
|
.WithHeader("Accept", "application/sparql-results+json")
|
||||||
|
.PostUrlEncodedAsync(new { query = SparqlQuery });
|
||||||
|
|
||||||
|
var json = await response.GetStringAsync();
|
||||||
|
var parsed = JObject.Parse(json);
|
||||||
|
|
||||||
|
var names = parsed["results"]?["bindings"]?
|
||||||
|
.Select(b => b["name"]?["value"]?.ToString())
|
||||||
|
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(n => n)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (names is { Count: > 0 })
|
||||||
|
{
|
||||||
|
_providers = names!;
|
||||||
|
Console.WriteLine($"[NetworkProviderService] Loaded {names.Count} providers from ELCOM.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[NetworkProviderService] SPARQL query returned no results.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[NetworkProviderService] Failed to fetch providers: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -185,6 +185,29 @@ public static class ReportAggregationService
|
||||||
foreach (var old in overlapping)
|
foreach (var old in overlapping)
|
||||||
Db.WeeklyReports.Delete(r => r.Id == old.Id);
|
Db.WeeklyReports.Delete(r => r.Id == old.Id);
|
||||||
|
|
||||||
|
// Serialize full response (minus AI insight) for cache-first serving
|
||||||
|
var reportForCache = new WeeklyReportResponse
|
||||||
|
{
|
||||||
|
InstallationName = report.InstallationName,
|
||||||
|
PeriodStart = report.PeriodStart,
|
||||||
|
PeriodEnd = report.PeriodEnd,
|
||||||
|
CurrentWeek = report.CurrentWeek,
|
||||||
|
PreviousWeek = report.PreviousWeek,
|
||||||
|
TotalEnergySaved = report.TotalEnergySaved,
|
||||||
|
TotalSavingsCHF = report.TotalSavingsCHF,
|
||||||
|
DaysEquivalent = report.DaysEquivalent,
|
||||||
|
SelfSufficiencyPercent = report.SelfSufficiencyPercent,
|
||||||
|
SelfConsumptionPercent = report.SelfConsumptionPercent,
|
||||||
|
BatteryEfficiencyPercent = report.BatteryEfficiencyPercent,
|
||||||
|
GridDependencyPercent = report.GridDependencyPercent,
|
||||||
|
PvChangePercent = report.PvChangePercent,
|
||||||
|
ConsumptionChangePercent = report.ConsumptionChangePercent,
|
||||||
|
GridImportChangePercent = report.GridImportChangePercent,
|
||||||
|
DailyData = report.DailyData,
|
||||||
|
Behavior = report.Behavior,
|
||||||
|
AiInsight = "", // Language-dependent; stored in AiInsightCache
|
||||||
|
};
|
||||||
|
|
||||||
var summary = new WeeklyReportSummary
|
var summary = new WeeklyReportSummary
|
||||||
{
|
{
|
||||||
InstallationId = installationId,
|
InstallationId = installationId,
|
||||||
|
|
@ -207,6 +230,7 @@ public static class ReportAggregationService
|
||||||
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
|
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
|
||||||
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
|
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
|
||||||
AiInsight = report.AiInsight,
|
AiInsight = report.AiInsight,
|
||||||
|
ResponseJson = JsonConvert.SerializeObject(reportForCache),
|
||||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -378,7 +402,8 @@ public static class ReportAggregationService
|
||||||
installationName, monthName, days.Count,
|
installationName, monthName, days.Count,
|
||||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||||
selfSufficiency, batteryEff, language);
|
selfSufficiency, batteryEff, language,
|
||||||
|
installation?.Location, installation?.Country, installation?.Region);
|
||||||
|
|
||||||
var monthlySummary = new MonthlyReportSummary
|
var monthlySummary = new MonthlyReportSummary
|
||||||
{
|
{
|
||||||
|
|
@ -573,11 +598,31 @@ public static class ReportAggregationService
|
||||||
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
|
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs a full WeeklyReportResponse from a cached WeeklyReportSummary.
|
||||||
|
/// Returns null if ResponseJson is empty (old records without cache data).
|
||||||
|
/// AI insight is fetched/generated per-language via AiInsightCache.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<WeeklyReportResponse?> ToWeeklyReportResponseAsync(
|
||||||
|
WeeklyReportSummary summary, String language)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(summary.ResponseJson))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var response = JsonConvert.DeserializeObject<WeeklyReportResponse>(summary.ResponseJson);
|
||||||
|
if (response == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
response.AiInsight = await GetOrGenerateWeeklyInsightAsync(summary, language);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
|
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
|
||||||
public static Task<String> GetOrGenerateMonthlyInsightAsync(
|
public static Task<String> GetOrGenerateMonthlyInsightAsync(
|
||||||
MonthlyReportSummary report, String language)
|
MonthlyReportSummary report, String language)
|
||||||
{
|
{
|
||||||
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
|
var installation = Db.GetInstallationById(report.InstallationId);
|
||||||
|
var installationName = installation?.InstallationName
|
||||||
?? $"Installation {report.InstallationId}";
|
?? $"Installation {report.InstallationId}";
|
||||||
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
|
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
|
||||||
return GetOrGenerateInsightAsync("monthly", report.Id, language,
|
return GetOrGenerateInsightAsync("monthly", report.Id, language,
|
||||||
|
|
@ -587,7 +632,8 @@ public static class ReportAggregationService
|
||||||
report.TotalGridImport, report.TotalGridExport,
|
report.TotalGridImport, report.TotalGridExport,
|
||||||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
|
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
|
||||||
|
installation?.Location, installation?.Country, installation?.Region));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
|
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
|
||||||
|
|
@ -670,7 +716,8 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
|
||||||
Double totalBattChg, Double totalBattDis,
|
Double totalBattChg, Double totalBattDis,
|
||||||
Double energySaved, Double savingsCHF,
|
Double energySaved, Double savingsCHF,
|
||||||
Double selfSufficiency, Double batteryEff,
|
Double selfSufficiency, Double batteryEff,
|
||||||
String language = "en")
|
String language = "en",
|
||||||
|
String? location = null, String? country = null, String? region = null)
|
||||||
{
|
{
|
||||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
if (String.IsNullOrWhiteSpace(apiKey))
|
if (String.IsNullOrWhiteSpace(apiKey))
|
||||||
|
|
@ -681,24 +728,34 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
|
||||||
// Determine which metric is weakest so the tip can be targeted
|
// Determine which metric is weakest so the tip can be targeted
|
||||||
var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general";
|
var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general";
|
||||||
|
|
||||||
|
// Fetch weather forecast for the installation's location
|
||||||
|
var forecast = await WeatherService.GetForecastAsync(location, country, region);
|
||||||
|
var weatherBlock = forecast != null
|
||||||
|
? "\n" + WeatherService.FormatForPrompt(forecast) + "\n"
|
||||||
|
: "";
|
||||||
|
var weatherTipHint = forecast != null
|
||||||
|
? " Consider the upcoming 7-day weather forecast when suggesting the tip."
|
||||||
|
: "";
|
||||||
|
|
||||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||||
|
|
||||||
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
|
Write a concise monthly performance summary in {langName} (5 bullet points, plain text, no markdown).
|
||||||
|
|
||||||
MONTHLY FACTS for {monthName} ({weekCount} days of data):
|
MONTHLY FACTS for {monthName} ({weekCount} days of data):
|
||||||
- PV production: {totalPv:F1} kWh
|
- PV production: {totalPv:F1} kWh
|
||||||
- Total consumption: {totalConsump:F1} kWh
|
- Total consumption: {totalConsump:F1} kWh
|
||||||
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid)
|
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar + battery, not bought from grid)
|
||||||
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}%
|
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}%
|
||||||
- Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh)
|
- Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh)
|
||||||
|
{weatherBlock}
|
||||||
INSTRUCTIONS:
|
INSTRUCTIONS:
|
||||||
1. Savings: state exactly how much energy and money was saved this month. Positive framing.
|
1. Savings: state exactly how much energy and money was saved this month. Positive framing.
|
||||||
2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""Your solar system covered X% of your home's energy needs""). Do NOT mention battery here. Do NOT mention raw grid import kWh.
|
2. Energy independence: state the self-sufficiency percentage and what it means — X% of the home's energy came from the combined solar and battery system, only Y% was purchased from the grid. Do NOT repeat raw grid import kWh.
|
||||||
3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention.
|
3. Solar production: state how much the solar system produced this month and the daily average. Keep it factual. Do NOT repeat self-sufficiency percentage here.
|
||||||
4. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}. If general, suggest the most impactful habit change based on the numbers above.
|
4. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention. Do NOT repeat self-sufficiency percentage here.
|
||||||
|
5. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}.{weatherTipHint} If general, suggest the most impactful habit change based on the numbers above.
|
||||||
|
|
||||||
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 5 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||||
|
|
||||||
return await CallMistralAsync(apiKey, prompt);
|
return await CallMistralAsync(apiKey, prompt);
|
||||||
}
|
}
|
||||||
|
|
@ -718,7 +775,7 @@ Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisk
|
||||||
var langName = GetLanguageName(language);
|
var langName = GetLanguageName(language);
|
||||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||||
|
|
||||||
Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown).
|
Write a concise annual performance summary in {langName} (5 bullet points, plain text, no markdown).
|
||||||
|
|
||||||
ANNUAL FACTS for {year} ({monthCount} months of data):
|
ANNUAL FACTS for {year} ({monthCount} months of data):
|
||||||
- Total PV production: {totalPv:F1} kWh
|
- Total PV production: {totalPv:F1} kWh
|
||||||
|
|
@ -731,11 +788,12 @@ ANNUAL FACTS for {year} ({monthCount} months of data):
|
||||||
|
|
||||||
INSTRUCTIONS:
|
INSTRUCTIONS:
|
||||||
1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided.
|
1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided.
|
||||||
2. System performance: comment on PV production and battery health indicators.
|
2. Energy independence: state the self-sufficiency percentage — X% of the home's energy came from the combined solar and battery system. Do NOT repeat raw grid import kWh.
|
||||||
3. Year-over-year readiness: note any trends or areas of improvement.
|
3. Solar production: state total PV production for the year. Keep it factual. Do NOT repeat self-sufficiency percentage here.
|
||||||
4. Looking ahead: one strategic recommendation for the coming year.
|
4. Battery: comment on battery efficiency. If efficiency < 80%, note it may need attention. Do NOT repeat self-sufficiency percentage here.
|
||||||
|
5. Looking ahead: one strategic recommendation for the coming year.
|
||||||
|
|
||||||
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 5 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||||
|
|
||||||
return await CallMistralAsync(apiKey, prompt);
|
return await CallMistralAsync(apiKey, prompt);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ public static class ReportEmailService
|
||||||
/// Sends the weekly report as a nicely formatted HTML email in the user's language.
|
/// 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.
|
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en")
|
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en", string customerName = null)
|
||||||
{
|
{
|
||||||
var strings = GetStrings(language);
|
var strings = GetStrings(language);
|
||||||
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
|
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
|
||||||
var html = BuildHtmlEmail(report, strings);
|
var html = BuildHtmlEmail(report, strings, customerName);
|
||||||
|
|
||||||
var config = await ReadMailerConfig();
|
var config = await ReadMailerConfig();
|
||||||
|
|
||||||
|
|
@ -80,7 +80,8 @@ public static class ReportEmailService
|
||||||
string GridIn,
|
string GridIn,
|
||||||
string GridOut,
|
string GridOut,
|
||||||
string BattInOut,
|
string BattInOut,
|
||||||
string Footer
|
string Footer,
|
||||||
|
string FooterLink
|
||||||
);
|
);
|
||||||
|
|
||||||
private static EmailStrings GetStrings(string language) => language switch
|
private static EmailStrings GetStrings(string language) => language switch
|
||||||
|
|
@ -104,8 +105,8 @@ public static class ReportEmailService
|
||||||
StayedAtHome: "Solar + Batterie, nicht vom Netz",
|
StayedAtHome: "Solar + Batterie, nicht vom Netz",
|
||||||
EstMoneySaved: "Geschätzte Ersparnis",
|
EstMoneySaved: "Geschätzte Ersparnis",
|
||||||
AtRate: "bei 0.39 CHF/kWh",
|
AtRate: "bei 0.39 CHF/kWh",
|
||||||
SolarCoverage: "Eigenversorgung",
|
SolarCoverage: "Energieunabhängigkeit",
|
||||||
FromSolar: "aus Solar + Batterie",
|
FromSolar: "aus eigenem Solar + Batterie System",
|
||||||
BatteryEff: "Batterie-Eff.",
|
BatteryEff: "Batterie-Eff.",
|
||||||
OutVsIn: "Entladung vs. Ladung",
|
OutVsIn: "Entladung vs. Ladung",
|
||||||
Day: "Tag",
|
Day: "Tag",
|
||||||
|
|
@ -113,7 +114,8 @@ public static class ReportEmailService
|
||||||
GridIn: "Netz Ein",
|
GridIn: "Netz Ein",
|
||||||
GridOut: "Netz Aus",
|
GridOut: "Netz Aus",
|
||||||
BattInOut: "Batt. Laden/Entl.",
|
BattInOut: "Batt. Laden/Entl.",
|
||||||
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
FooterLink: "Detaillierte Berichte ansehen auf monitor.inesco.energy"
|
||||||
),
|
),
|
||||||
"fr" => new EmailStrings(
|
"fr" => new EmailStrings(
|
||||||
Title: "Rapport de performance hebdomadaire",
|
Title: "Rapport de performance hebdomadaire",
|
||||||
|
|
@ -134,8 +136,8 @@ public static class ReportEmailService
|
||||||
StayedAtHome: "solaire + batterie, non achetée au réseau",
|
StayedAtHome: "solaire + batterie, non achetée au réseau",
|
||||||
EstMoneySaved: "Économies estimées",
|
EstMoneySaved: "Économies estimées",
|
||||||
AtRate: "à 0.39 CHF/kWh",
|
AtRate: "à 0.39 CHF/kWh",
|
||||||
SolarCoverage: "Autosuffisance",
|
SolarCoverage: "Indépendance énergétique",
|
||||||
FromSolar: "du solaire + batterie",
|
FromSolar: "de votre système solaire + batterie",
|
||||||
BatteryEff: "Eff. batterie",
|
BatteryEff: "Eff. batterie",
|
||||||
OutVsIn: "décharge vs charge",
|
OutVsIn: "décharge vs charge",
|
||||||
Day: "Jour",
|
Day: "Jour",
|
||||||
|
|
@ -143,7 +145,8 @@ public static class ReportEmailService
|
||||||
GridIn: "Réseau Ent.",
|
GridIn: "Réseau Ent.",
|
||||||
GridOut: "Réseau Sor.",
|
GridOut: "Réseau Sor.",
|
||||||
BattInOut: "Batt. Ch./Déch.",
|
BattInOut: "Batt. Ch./Déch.",
|
||||||
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
FooterLink: "Consultez vos rapports détaillés sur monitor.inesco.energy"
|
||||||
),
|
),
|
||||||
"it" => new EmailStrings(
|
"it" => new EmailStrings(
|
||||||
Title: "Rapporto settimanale delle prestazioni",
|
Title: "Rapporto settimanale delle prestazioni",
|
||||||
|
|
@ -164,8 +167,8 @@ public static class ReportEmailService
|
||||||
StayedAtHome: "solare + batteria, non acquistata dalla rete",
|
StayedAtHome: "solare + batteria, non acquistata dalla rete",
|
||||||
EstMoneySaved: "Risparmio stimato",
|
EstMoneySaved: "Risparmio stimato",
|
||||||
AtRate: "a 0.39 CHF/kWh",
|
AtRate: "a 0.39 CHF/kWh",
|
||||||
SolarCoverage: "Autosufficienza",
|
SolarCoverage: "Indipendenza energetica",
|
||||||
FromSolar: "da solare + batteria",
|
FromSolar: "dal proprio impianto solare + batteria",
|
||||||
BatteryEff: "Eff. batteria",
|
BatteryEff: "Eff. batteria",
|
||||||
OutVsIn: "scarica vs carica",
|
OutVsIn: "scarica vs carica",
|
||||||
Day: "Giorno",
|
Day: "Giorno",
|
||||||
|
|
@ -173,7 +176,8 @@ public static class ReportEmailService
|
||||||
GridIn: "Rete Ent.",
|
GridIn: "Rete Ent.",
|
||||||
GridOut: "Rete Usc.",
|
GridOut: "Rete Usc.",
|
||||||
BattInOut: "Batt. Car./Sc.",
|
BattInOut: "Batt. Car./Sc.",
|
||||||
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
FooterLink: "Visualizza i tuoi report dettagliati su monitor.inesco.energy"
|
||||||
),
|
),
|
||||||
_ => new EmailStrings(
|
_ => new EmailStrings(
|
||||||
Title: "Weekly Performance Report",
|
Title: "Weekly Performance Report",
|
||||||
|
|
@ -194,8 +198,8 @@ public static class ReportEmailService
|
||||||
StayedAtHome: "solar + battery, not bought from grid",
|
StayedAtHome: "solar + battery, not bought from grid",
|
||||||
EstMoneySaved: "Est. Money Saved",
|
EstMoneySaved: "Est. Money Saved",
|
||||||
AtRate: "at 0.39 CHF/kWh",
|
AtRate: "at 0.39 CHF/kWh",
|
||||||
SolarCoverage: "Self-Sufficiency",
|
SolarCoverage: "Energy Independence",
|
||||||
FromSolar: "from solar + battery",
|
FromSolar: "from your own solar + battery system",
|
||||||
BatteryEff: "Battery Eff.",
|
BatteryEff: "Battery Eff.",
|
||||||
OutVsIn: "discharge vs charge",
|
OutVsIn: "discharge vs charge",
|
||||||
Day: "Day",
|
Day: "Day",
|
||||||
|
|
@ -203,16 +207,17 @@ public static class ReportEmailService
|
||||||
GridIn: "Grid In",
|
GridIn: "Grid In",
|
||||||
GridOut: "Grid Out",
|
GridOut: "Grid Out",
|
||||||
BattInOut: "Batt. Ch./Dis.",
|
BattInOut: "Batt. Ch./Dis.",
|
||||||
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
FooterLink: "View your detailed reports at monitor.inesco.energy"
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── HTML email template ─────────────────────────────────────────────
|
// ── HTML email template ─────────────────────────────────────────────
|
||||||
|
|
||||||
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en")
|
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en", string customerName = null)
|
||||||
=> BuildHtmlEmail(r, GetStrings(language));
|
=> BuildHtmlEmail(r, GetStrings(language), customerName);
|
||||||
|
|
||||||
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s)
|
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s, string customerName = null)
|
||||||
{
|
{
|
||||||
var cur = r.CurrentWeek;
|
var cur = r.CurrentWeek;
|
||||||
var prev = r.PreviousWeek;
|
var prev = r.PreviousWeek;
|
||||||
|
|
@ -396,6 +401,7 @@ public static class ReportEmailService
|
||||||
<tr>
|
<tr>
|
||||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||||
{s.Footer}
|
{s.Footer}
|
||||||
|
<div style=""margin-top:10px""><a href=""https://monitor.inesco.energy"" style=""color:#999;text-decoration:underline"">{s.FooterLink}</a></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -455,7 +461,8 @@ public static class ReportEmailService
|
||||||
MonthlyReportSummary report,
|
MonthlyReportSummary report,
|
||||||
string installationName,
|
string installationName,
|
||||||
string recipientEmail,
|
string recipientEmail,
|
||||||
string language = "en")
|
string language = "en",
|
||||||
|
string customerName = null)
|
||||||
{
|
{
|
||||||
var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn };
|
var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn };
|
||||||
var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString();
|
var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString();
|
||||||
|
|
@ -465,7 +472,7 @@ public static class ReportEmailService
|
||||||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||||
$"{report.WeekCount} {s.CountLabel}", s);
|
$"{report.WeekCount} {s.CountLabel}", s, customerName);
|
||||||
|
|
||||||
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||||
}
|
}
|
||||||
|
|
@ -474,7 +481,8 @@ public static class ReportEmailService
|
||||||
YearlyReportSummary report,
|
YearlyReportSummary report,
|
||||||
string installationName,
|
string installationName,
|
||||||
string recipientEmail,
|
string recipientEmail,
|
||||||
string language = "en")
|
string language = "en",
|
||||||
|
string customerName = null)
|
||||||
{
|
{
|
||||||
var s = GetAggregatedStrings(language, "yearly");
|
var s = GetAggregatedStrings(language, "yearly");
|
||||||
var subject = $"{s.Title} — {installationName} ({report.Year})";
|
var subject = $"{s.Title} — {installationName} ({report.Year})";
|
||||||
|
|
@ -482,7 +490,7 @@ public static class ReportEmailService
|
||||||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||||
$"{report.MonthCount} {s.CountLabel}", s);
|
$"{report.MonthCount} {s.CountLabel}", s, customerName);
|
||||||
|
|
||||||
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||||
}
|
}
|
||||||
|
|
@ -518,7 +526,8 @@ public static class ReportEmailService
|
||||||
string GridImport, string GridExport, string BatteryInOut,
|
string GridImport, string GridExport, string BatteryInOut,
|
||||||
string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved,
|
string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved,
|
||||||
string AtRate, string SolarCoverage, string FromSolar,
|
string AtRate, string SolarCoverage, string FromSolar,
|
||||||
string BatteryEff, string OutVsIn, string CountLabel, string Footer
|
string BatteryEff, string OutVsIn, string CountLabel, string Footer,
|
||||||
|
string FooterLink
|
||||||
);
|
);
|
||||||
|
|
||||||
private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch
|
private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch
|
||||||
|
|
@ -527,50 +536,58 @@ public static class ReportEmailService
|
||||||
"Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat",
|
"Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat",
|
||||||
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
||||||
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
||||||
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
"Energieunabhängigkeit", "aus eigenem Solar + Batterie System", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||||
"Tage aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"Tage aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"Detaillierte Berichte ansehen auf monitor.inesco.energy"),
|
||||||
("de", "yearly") => new AggregatedEmailStrings(
|
("de", "yearly") => new AggregatedEmailStrings(
|
||||||
"Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr",
|
"Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr",
|
||||||
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
||||||
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
||||||
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
"Energieunabhängigkeit", "aus eigenem Solar + Batterie System", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||||
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"Detaillierte Berichte ansehen auf monitor.inesco.energy"),
|
||||||
("fr", "monthly") => new AggregatedEmailStrings(
|
("fr", "monthly") => new AggregatedEmailStrings(
|
||||||
"Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois",
|
"Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois",
|
||||||
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
||||||
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||||
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
"Indépendance énergétique", "de votre système solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||||
"jours agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"jours agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"Consultez vos rapports détaillés sur monitor.inesco.energy"),
|
||||||
("fr", "yearly") => new AggregatedEmailStrings(
|
("fr", "yearly") => new AggregatedEmailStrings(
|
||||||
"Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année",
|
"Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année",
|
||||||
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
||||||
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||||
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
"Indépendance énergétique", "de votre système solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||||
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"Consultez vos rapports détaillés sur monitor.inesco.energy"),
|
||||||
("it", "monthly") => new AggregatedEmailStrings(
|
("it", "monthly") => new AggregatedEmailStrings(
|
||||||
"Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese",
|
"Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese",
|
||||||
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
||||||
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||||
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
"Indipendenza energetica", "dal proprio impianto solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||||
"giorni aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"giorni aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"Visualizza i tuoi report dettagliati su monitor.inesco.energy"),
|
||||||
("it", "yearly") => new AggregatedEmailStrings(
|
("it", "yearly") => new AggregatedEmailStrings(
|
||||||
"Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno",
|
"Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno",
|
||||||
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
||||||
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||||
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
"Indipendenza energetica", "dal proprio impianto solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||||
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"Visualizza i tuoi report dettagliati su monitor.inesco.energy"),
|
||||||
(_, "monthly") => new AggregatedEmailStrings(
|
(_, "monthly") => new AggregatedEmailStrings(
|
||||||
"Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month",
|
"Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month",
|
||||||
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
||||||
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
||||||
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
"Energy Independence", "from your own solar + battery system", "Battery Eff.", "discharge vs charge",
|
||||||
"days aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
"days aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"View your detailed reports at monitor.inesco.energy"),
|
||||||
_ => new AggregatedEmailStrings(
|
_ => new AggregatedEmailStrings(
|
||||||
"Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year",
|
"Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year",
|
||||||
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
||||||
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
||||||
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
"Energy Independence", "from your own solar + battery system", "Battery Eff.", "discharge vs charge",
|
||||||
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>")
|
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||||
|
"View your detailed reports at monitor.inesco.energy")
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Aggregated HTML email template ────────────────────────────────────
|
// ── Aggregated HTML email template ────────────────────────────────────
|
||||||
|
|
@ -580,7 +597,7 @@ public static class ReportEmailService
|
||||||
double pvProduction, double consumption, double gridImport, double gridExport,
|
double pvProduction, double consumption, double gridImport, double gridExport,
|
||||||
double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF,
|
double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF,
|
||||||
double selfSufficiency, double batteryEfficiency, string aiInsight,
|
double selfSufficiency, double batteryEfficiency, string aiInsight,
|
||||||
string countLabel, AggregatedEmailStrings s)
|
string countLabel, AggregatedEmailStrings s, string customerName = null)
|
||||||
{
|
{
|
||||||
var insightLines = aiInsight
|
var insightLines = aiInsight
|
||||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
|
@ -660,6 +677,7 @@ public static class ReportEmailService
|
||||||
<tr>
|
<tr>
|
||||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||||
{s.Footer}
|
{s.Footer}
|
||||||
|
<div style=""margin-top:10px""><a href=""https://monitor.inesco.energy"" style=""color:#999;text-decoration:underline"">{s.FooterLink}</a></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
using Flurl.Http;
|
||||||
|
using InnovEnergy.App.Backend.Database;
|
||||||
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates AI-powered diagnoses for support tickets.
|
||||||
|
/// Runs async after ticket creation; stores result in TicketAiDiagnosis table.
|
||||||
|
/// </summary>
|
||||||
|
public static class TicketDiagnosticService
|
||||||
|
{
|
||||||
|
private static string _apiKey = "";
|
||||||
|
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
Console.Error.WriteLine("[TicketDiagnosticService] MISTRAL_API_KEY not set – ticket AI disabled.");
|
||||||
|
else
|
||||||
|
_apiKey = apiKey;
|
||||||
|
|
||||||
|
Console.WriteLine("[TicketDiagnosticService] initialised.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called fire-and-forget after ticket creation.
|
||||||
|
/// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task DiagnoseTicketAsync(Int64 ticketId)
|
||||||
|
{
|
||||||
|
var ticket = Db.GetTicketById(ticketId);
|
||||||
|
if (ticket is null) return;
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(ticket.InstallationId);
|
||||||
|
if (installation is null) return;
|
||||||
|
|
||||||
|
var diagnosis = new TicketAiDiagnosis
|
||||||
|
{
|
||||||
|
TicketId = ticketId,
|
||||||
|
Status = (Int32)DiagnosisStatus.Pending,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
Db.Create(diagnosis);
|
||||||
|
|
||||||
|
if (!IsEnabled)
|
||||||
|
{
|
||||||
|
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
|
||||||
|
Db.Update(diagnosis);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnosis.Status = (Int32)DiagnosisStatus.Analyzing;
|
||||||
|
Db.Update(diagnosis);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var productName = ((ProductType)installation.Product).ToString();
|
||||||
|
|
||||||
|
var recentErrors = Db.Errors
|
||||||
|
.Where(e => e.InstallationId == ticket.InstallationId)
|
||||||
|
.OrderByDescending(e => e.Date)
|
||||||
|
.ToList()
|
||||||
|
.Select(e => e.Description)
|
||||||
|
.Distinct()
|
||||||
|
.Take(5)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var prompt = BuildPrompt(ticket, productName, recentErrors);
|
||||||
|
var result = await CallMistralAsync(prompt);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
diagnosis.Status = (Int32)DiagnosisStatus.Completed;
|
||||||
|
diagnosis.RootCause = result.RootCause;
|
||||||
|
diagnosis.Confidence = result.Confidence;
|
||||||
|
diagnosis.RecommendedActions = result.RecommendedActionsJson;
|
||||||
|
diagnosis.CompletedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[TicketDiagnosticService] {ex.Message}");
|
||||||
|
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db.Update(diagnosis);
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = ticketId,
|
||||||
|
EventType = (Int32)TimelineEventType.AiDiagnosisAttached,
|
||||||
|
Description = diagnosis.Status == (Int32)DiagnosisStatus.Completed
|
||||||
|
? "AI diagnosis completed."
|
||||||
|
: "AI diagnosis failed.",
|
||||||
|
ActorType = (Int32)TimelineActorType.AiAgent,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors)
|
||||||
|
{
|
||||||
|
var recentList = recentErrors.Count > 0
|
||||||
|
? string.Join(", ", recentErrors)
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
return $@"You are a senior field technician for {productName} battery energy storage systems.
|
||||||
|
A support ticket has been submitted with the following details:
|
||||||
|
Subject: {ticket.Subject}
|
||||||
|
Description: {ticket.Description}
|
||||||
|
Category: {(TicketCategory)ticket.Category}
|
||||||
|
Priority: {(TicketPriority)ticket.Priority}
|
||||||
|
Recent system alarms: {recentList}
|
||||||
|
|
||||||
|
Analyze this ticket and respond in JSON only — no markdown, no explanation outside JSON:
|
||||||
|
{{
|
||||||
|
""rootCause"": ""One concise sentence describing the most likely root cause."",
|
||||||
|
""confidence"": 0.85,
|
||||||
|
""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""]
|
||||||
|
}}
|
||||||
|
Confidence must be a number between 0.0 and 1.0.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TicketDiagnosisResult?> CallMistralAsync(string prompt)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
model = "mistral-small-latest",
|
||||||
|
messages = new[] { new { role = "user", content = prompt } },
|
||||||
|
max_tokens = 300,
|
||||||
|
temperature = 0.2
|
||||||
|
};
|
||||||
|
|
||||||
|
var text = await MistralUrl
|
||||||
|
.WithHeader("Authorization", $"Bearer {_apiKey}")
|
||||||
|
.PostJsonAsync(body)
|
||||||
|
.ReceiveString();
|
||||||
|
|
||||||
|
var envelope = JsonConvert.DeserializeObject<dynamic>(text);
|
||||||
|
var content = (string?)envelope?.choices?[0]?.message?.content;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content)) return null;
|
||||||
|
|
||||||
|
var json = content.Trim();
|
||||||
|
if (json.StartsWith("```"))
|
||||||
|
{
|
||||||
|
var nl = json.IndexOf('\n');
|
||||||
|
if (nl >= 0) json = json[(nl + 1)..];
|
||||||
|
if (json.EndsWith("```")) json = json[..^3];
|
||||||
|
json = json.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = JsonConvert.DeserializeObject<TicketDiagnosisRaw>(json);
|
||||||
|
if (parsed is null) return null;
|
||||||
|
|
||||||
|
return new TicketDiagnosisResult
|
||||||
|
{
|
||||||
|
RootCause = parsed.RootCause,
|
||||||
|
Confidence = parsed.Confidence,
|
||||||
|
RecommendedActionsJson = JsonConvert.SerializeObject(parsed.RecommendedActions ?? Array.Empty<string>())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[TicketDiagnosticService] HTTP error: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class TicketDiagnosisRaw
|
||||||
|
{
|
||||||
|
[JsonProperty("rootCause")]
|
||||||
|
public String? RootCause { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("confidence")]
|
||||||
|
public Double? Confidence { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("recommendedActions")]
|
||||||
|
public String[]? RecommendedActions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class TicketDiagnosisResult
|
||||||
|
{
|
||||||
|
public String? RootCause { get; set; }
|
||||||
|
public Double? Confidence { get; set; }
|
||||||
|
public String? RecommendedActionsJson { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
using Flurl.Http;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
|
public static class WeatherService
|
||||||
|
{
|
||||||
|
public record DailyWeather(
|
||||||
|
string Date,
|
||||||
|
double TempMin,
|
||||||
|
double TempMax,
|
||||||
|
double SunshineHours,
|
||||||
|
double PrecipitationMm,
|
||||||
|
string Description
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, (double Lat, double Lon)> GeoCache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a 7-day weather forecast for the given city, or null on any failure.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<List<DailyWeather>?> GetForecastAsync(string? city, string? country, string? region = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(city))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var coords = await GeocodeAsync(city, region);
|
||||||
|
if (coords == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var (lat, lon) = coords.Value;
|
||||||
|
return await FetchForecastAsync(lat, lon);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[WeatherService] Error fetching forecast for '{city}': {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a forecast list into a compact text block for AI prompt injection.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatForPrompt(List<DailyWeather> forecast)
|
||||||
|
{
|
||||||
|
var lines = forecast.Select(d =>
|
||||||
|
{
|
||||||
|
var date = DateTime.Parse(d.Date);
|
||||||
|
var dayName = date.ToString("ddd dd MMM");
|
||||||
|
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||||||
|
});
|
||||||
|
|
||||||
|
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a geocodable city name from a Location field that may contain a full address.
|
||||||
|
/// Handles Swiss formats like "Street 5, 8604 Volketswil" → "Volketswil"
|
||||||
|
/// Also tries the Region field as fallback.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> ExtractSearchTerms(string city, string? region)
|
||||||
|
{
|
||||||
|
// If it contains a comma, try the part after the last comma (often "PostalCode City")
|
||||||
|
if (city.Contains(','))
|
||||||
|
{
|
||||||
|
var afterComma = city.Split(',').Last().Trim();
|
||||||
|
// Strip leading postal code (digits) → "8604 Volketswil" → "Volketswil"
|
||||||
|
var withoutPostal = System.Text.RegularExpressions.Regex.Replace(afterComma, @"^\d+\s*", "").Trim();
|
||||||
|
if (!string.IsNullOrEmpty(withoutPostal))
|
||||||
|
yield return withoutPostal;
|
||||||
|
if (!string.IsNullOrEmpty(afterComma))
|
||||||
|
yield return afterComma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the raw value as-is
|
||||||
|
yield return city;
|
||||||
|
|
||||||
|
// Fallback to Region
|
||||||
|
if (!string.IsNullOrWhiteSpace(region))
|
||||||
|
yield return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(double Lat, double Lon)?> GeocodeAsync(string city, string? region = null)
|
||||||
|
{
|
||||||
|
if (GeoCache.TryGetValue(city, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
foreach (var term in ExtractSearchTerms(city, region))
|
||||||
|
{
|
||||||
|
var url = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(term)}&count=1&language=en";
|
||||||
|
var json = await url.GetStringAsync();
|
||||||
|
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||||||
|
|
||||||
|
if (data?.results != null && data.results.Count > 0)
|
||||||
|
{
|
||||||
|
var lat = (double)data.results[0].latitude;
|
||||||
|
var lon = (double)data.results[0].longitude;
|
||||||
|
|
||||||
|
GeoCache[city] = (lat, lon);
|
||||||
|
Console.WriteLine($"[WeatherService] Geocoded '{city}' (matched on '{term}') → ({lat:F4}, {lon:F4})");
|
||||||
|
return (lat, lon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[WeatherService] Could not geocode location: '{city}'");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<DailyWeather>?> FetchForecastAsync(double lat, double lon)
|
||||||
|
{
|
||||||
|
var url = $"https://api.open-meteo.com/v1/forecast"
|
||||||
|
+ $"?latitude={lat}&longitude={lon}"
|
||||||
|
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
|
||||||
|
+ "&timezone=Europe/Zurich&forecast_days=7";
|
||||||
|
|
||||||
|
var json = await url.GetStringAsync();
|
||||||
|
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||||||
|
|
||||||
|
if (data?.daily == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var dates = data.daily.time;
|
||||||
|
var tempMax = data.daily.temperature_2m_max;
|
||||||
|
var tempMin = data.daily.temperature_2m_min;
|
||||||
|
var sun = data.daily.sunshine_duration;
|
||||||
|
var precip = data.daily.precipitation_sum;
|
||||||
|
var codes = data.daily.weathercode;
|
||||||
|
|
||||||
|
var forecast = new List<DailyWeather>();
|
||||||
|
for (int i = 0; i < dates.Count; i++)
|
||||||
|
{
|
||||||
|
forecast.Add(new DailyWeather(
|
||||||
|
Date: (string)dates[i],
|
||||||
|
TempMin: (double)tempMin[i],
|
||||||
|
TempMax: (double)tempMax[i],
|
||||||
|
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
|
||||||
|
PrecipitationMm: (double)precip[i],
|
||||||
|
Description: WeatherCodeToDescription((int)codes[i])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[WeatherService] Fetched {forecast.Count}-day forecast.");
|
||||||
|
return forecast;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WeatherCodeToDescription(int code) => code switch
|
||||||
|
{
|
||||||
|
0 => "Clear sky",
|
||||||
|
1 => "Mainly clear",
|
||||||
|
2 => "Partly cloudy",
|
||||||
|
3 => "Overcast",
|
||||||
|
45 => "Fog",
|
||||||
|
48 => "Depositing rime fog",
|
||||||
|
51 => "Light drizzle",
|
||||||
|
53 => "Moderate drizzle",
|
||||||
|
55 => "Dense drizzle",
|
||||||
|
61 => "Slight rain",
|
||||||
|
63 => "Moderate rain",
|
||||||
|
65 => "Heavy rain",
|
||||||
|
66 => "Light freezing rain",
|
||||||
|
67 => "Heavy freezing rain",
|
||||||
|
71 => "Slight snow",
|
||||||
|
73 => "Moderate snow",
|
||||||
|
75 => "Heavy snow",
|
||||||
|
77 => "Snow grains",
|
||||||
|
80 => "Slight showers",
|
||||||
|
81 => "Moderate showers",
|
||||||
|
82 => "Violent showers",
|
||||||
|
85 => "Slight snow showers",
|
||||||
|
86 => "Heavy snow showers",
|
||||||
|
95 => "Thunderstorm",
|
||||||
|
96 => "Thunderstorm with slight hail",
|
||||||
|
99 => "Thunderstorm with heavy hail",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using InnovEnergy.App.Backend.Database;
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
@ -9,6 +10,54 @@ public static class WeeklyReportService
|
||||||
{
|
{
|
||||||
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns xlsx files for the given installation whose date range overlaps [rangeStart, rangeEnd].
|
||||||
|
/// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx")
|
||||||
|
/// Falls back to all files if filenames can't be parsed.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(TmpReportDir))
|
||||||
|
return new List<string>();
|
||||||
|
|
||||||
|
var allFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList();
|
||||||
|
if (allFiles.Count == 0)
|
||||||
|
return allFiles;
|
||||||
|
|
||||||
|
// Try to filter by filename date range; fall back to all files if parsing fails
|
||||||
|
var year = rangeStart.Year;
|
||||||
|
var filtered = new List<string>();
|
||||||
|
|
||||||
|
foreach (var file in allFiles)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileNameWithoutExtension(file);
|
||||||
|
// Match pattern: {id}_MMDD_MMDD
|
||||||
|
var match = Regex.Match(name, @"_(\d{4})_(\d{4})$");
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
// Can't parse filename — include it to be safe
|
||||||
|
filtered.Add(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startStr = match.Groups[1].Value; // "0302"
|
||||||
|
var endStr = match.Groups[2].Value; // "0308"
|
||||||
|
|
||||||
|
if (!DateOnly.TryParseExact($"{year}-{startStr[..2]}-{startStr[2..]}", "yyyy-MM-dd", out var fileStart) ||
|
||||||
|
!DateOnly.TryParseExact($"{year}-{endStr[..2]}-{endStr[2..]}", "yyyy-MM-dd", out var fileEnd))
|
||||||
|
{
|
||||||
|
filtered.Add(file); // Can't parse — include to be safe
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include if date ranges overlap
|
||||||
|
if (fileStart <= rangeEnd && fileEnd >= rangeStart)
|
||||||
|
filtered.Add(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Calendar Week Helpers ──────────────────────────────────────────
|
// ── Calendar Week Helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -75,14 +124,13 @@ public static class WeeklyReportService
|
||||||
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
||||||
if (currentWeekDays.Count == 0)
|
if (currentWeekDays.Count == 0)
|
||||||
{
|
{
|
||||||
var xlsxFiles = Directory.Exists(TmpReportDir)
|
// Only parse xlsx files whose date range overlaps the needed weeks
|
||||||
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
|
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
|
||||||
: new List<String>();
|
|
||||||
|
|
||||||
if (xlsxFiles.Count > 0)
|
if (relevantFiles.Count > 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx.");
|
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
||||||
var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
||||||
currentWeekDays = allDaysParsed
|
currentWeekDays = allDaysParsed
|
||||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
@ -101,9 +149,32 @@ public static class WeeklyReportService
|
||||||
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
||||||
.Select(ToHourlyEnergyData).ToList();
|
.Select(ToHourlyEnergyData).ToList();
|
||||||
|
|
||||||
|
// 3b. Fallback: if DB empty, parse hourly data from xlsx
|
||||||
|
if (currentHourlyData.Count == 0)
|
||||||
|
{
|
||||||
|
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
|
||||||
|
|
||||||
|
if (relevantFiles.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[WeeklyReportService] Hourly DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
||||||
|
currentHourlyData = relevantFiles
|
||||||
|
.SelectMany(p => ExcelDataParser.ParseHourly(p))
|
||||||
|
.Where(h => { var date = DateOnly.FromDateTime(h.DateTime); return date >= curMon && date <= curSun; })
|
||||||
|
.ToList();
|
||||||
|
Console.WriteLine($"[WeeklyReportService] Parsed {currentHourlyData.Count} hourly records from xlsx.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get installation location for weather forecast
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
var location = installation?.Location;
|
||||||
|
var country = installation?.Country;
|
||||||
|
var region = installation?.Region;
|
||||||
|
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
|
||||||
|
|
||||||
return await GenerateReportFromDataAsync(
|
return await GenerateReportFromDataAsync(
|
||||||
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
|
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
|
||||||
curMon, curSun);
|
curMon, curSun, location, country, region);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Conversion helpers ─────────────────────────────────────────────
|
// ── Conversion helpers ─────────────────────────────────────────────
|
||||||
|
|
@ -144,7 +215,10 @@ public static class WeeklyReportService
|
||||||
string installationName,
|
string installationName,
|
||||||
string language = "en",
|
string language = "en",
|
||||||
DateOnly? weekStart = null,
|
DateOnly? weekStart = null,
|
||||||
DateOnly? weekEnd = null)
|
DateOnly? weekEnd = null,
|
||||||
|
string? location = null,
|
||||||
|
string? country = null,
|
||||||
|
string? region = null)
|
||||||
{
|
{
|
||||||
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
|
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
|
||||||
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
|
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
|
||||||
|
|
@ -188,7 +262,7 @@ public static class WeeklyReportService
|
||||||
var aiInsight = await GetAiInsightAsync(
|
var aiInsight = await GetAiInsightAsync(
|
||||||
currentWeekDays, currentSummary, previousSummary,
|
currentWeekDays, currentSummary, previousSummary,
|
||||||
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
||||||
behavior, installationName, language);
|
behavior, installationName, language, location, country, region);
|
||||||
|
|
||||||
return new WeeklyReportResponse
|
return new WeeklyReportResponse
|
||||||
{
|
{
|
||||||
|
|
@ -253,7 +327,10 @@ public static class WeeklyReportService
|
||||||
double totalSavingsCHF,
|
double totalSavingsCHF,
|
||||||
BehavioralPattern behavior,
|
BehavioralPattern behavior,
|
||||||
string installationName,
|
string installationName,
|
||||||
string language = "en")
|
string language = "en",
|
||||||
|
string? location = null,
|
||||||
|
string? country = null,
|
||||||
|
string? region = null)
|
||||||
{
|
{
|
||||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
|
@ -262,6 +339,12 @@ public static class WeeklyReportService
|
||||||
return "AI insight unavailable (API key not configured).";
|
return "AI insight unavailable (API key not configured).";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch weather forecast for the installation's location
|
||||||
|
var forecast = await WeatherService.GetForecastAsync(location, country, region);
|
||||||
|
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
|
||||||
|
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
|
||||||
|
if (forecast != null) Console.WriteLine($"[WeeklyReportService] Weather block:\n{weatherBlock}");
|
||||||
|
|
||||||
const double ElectricityPriceCHF = 0.39;
|
const double ElectricityPriceCHF = 0.39;
|
||||||
|
|
||||||
// Detect which components are present
|
// Detect which components are present
|
||||||
|
|
@ -278,7 +361,10 @@ public static class WeeklyReportService
|
||||||
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
|
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
|
||||||
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
|
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
|
||||||
|
|
||||||
// Behavioral facts as compact lines
|
// Check if we have meaningful hourly/behavioral data
|
||||||
|
var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0;
|
||||||
|
|
||||||
|
// Behavioral facts as compact lines (only when hourly data exists)
|
||||||
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "–" + FormatHour(behavior.PeakSolarEndHour);
|
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "–" + FormatHour(behavior.PeakSolarEndHour);
|
||||||
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
|
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
|
||||||
|
|
||||||
|
|
@ -303,6 +389,10 @@ public static class WeeklyReportService
|
||||||
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
|
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
// Behavioral section — only include when hourly data exists
|
||||||
|
var behavioralSection = "";
|
||||||
|
if (hasBehavior)
|
||||||
|
{
|
||||||
var pvBehaviorLines = hasPv ? $@"
|
var pvBehaviorLines = hasPv ? $@"
|
||||||
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
|
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
|
||||||
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
|
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
|
||||||
|
|
@ -313,28 +403,69 @@ public static class WeeklyReportService
|
||||||
|
|
||||||
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
|
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
|
||||||
|
|
||||||
|
behavioralSection = $@"
|
||||||
|
BEHAVIORAL PATTERN (from hourly data this week):
|
||||||
|
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||||||
|
- {weekdayWeekendLine}{pvBehaviorLines}
|
||||||
|
{gridBehaviorLine}
|
||||||
|
{battBehaviorLine}";
|
||||||
|
}
|
||||||
|
|
||||||
// Build conditional instructions
|
// Build conditional instructions
|
||||||
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
|
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
|
||||||
|
|
||||||
var instruction2 = hasPv
|
var instruction2 = hasPv
|
||||||
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
|
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
|
||||||
: hasGrid
|
: hasGrid
|
||||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})."
|
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
|
||||||
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
|
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
|
||||||
|
|
||||||
var instruction3 = hasBattery
|
var instruction3 = hasBattery
|
||||||
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
|
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
|
||||||
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
|
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
|
||||||
|
|
||||||
var instruction4 = hasPv
|
// Instruction 4 — adapts based on whether we have behavioral data
|
||||||
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has."
|
string instruction4;
|
||||||
: hasGrid
|
if (hasBehavior && hasPv)
|
||||||
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time."
|
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
|
||||||
: "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern.";
|
else if (hasBehavior && hasGrid)
|
||||||
|
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
|
||||||
|
else
|
||||||
|
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
|
||||||
|
|
||||||
|
// Instruction 5 — weather outlook with pattern-based predictions
|
||||||
|
var hasWeather = forecast != null;
|
||||||
|
var bulletCount = hasWeather ? 5 : 4;
|
||||||
|
var instruction5 = "";
|
||||||
|
if (hasWeather && hasPv)
|
||||||
|
{
|
||||||
|
// Compute avg daily PV production this week for reference
|
||||||
|
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
|
||||||
|
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
|
||||||
|
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
|
||||||
|
|
||||||
|
// Classify forecast days by sunshine potential
|
||||||
|
var sunnyDays = forecast.Where(d => d.SunshineHours >= 7).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
|
||||||
|
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
|
||||||
|
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
|
||||||
|
|
||||||
|
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
|
||||||
|
if (sunnyDays.Count > 0)
|
||||||
|
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
|
||||||
|
if (cloudyDays.Count > 0)
|
||||||
|
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
|
||||||
|
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
|
||||||
|
|
||||||
|
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
|
||||||
|
}
|
||||||
|
else if (hasWeather)
|
||||||
|
{
|
||||||
|
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
|
||||||
|
}
|
||||||
|
|
||||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||||
|
|
||||||
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||||
|
|
||||||
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
|
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.
|
||||||
|
|
||||||
|
|
@ -347,20 +478,16 @@ DAILY FACTS:
|
||||||
{pvDailyFact}
|
{pvDailyFact}
|
||||||
{battDailyFact}
|
{battDailyFact}
|
||||||
{gridDailyFact}
|
{gridDailyFact}
|
||||||
|
{behavioralSection}
|
||||||
BEHAVIORAL PATTERN (from hourly data this week):
|
{weatherBlock}
|
||||||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
|
||||||
- {weekdayWeekendLine}{pvBehaviorLines}
|
|
||||||
{gridBehaviorLine}
|
|
||||||
{battBehaviorLine}
|
|
||||||
|
|
||||||
INSTRUCTIONS:
|
INSTRUCTIONS:
|
||||||
{instruction1}
|
{instruction1}
|
||||||
{instruction2}
|
{instruction2}
|
||||||
{instruction3}
|
{instruction3}
|
||||||
{instruction4}
|
{instruction4}
|
||||||
|
{instruction5}
|
||||||
|
|
||||||
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the 4 bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly 4 bullet points — nothing before, nothing after.
|
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} bullet points — nothing before, nothing after.
|
||||||
IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -369,7 +496,7 @@ IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
||||||
{
|
{
|
||||||
model = "mistral-small-latest",
|
model = "mistral-small-latest",
|
||||||
messages = new[] { new { role = "user", content = prompt } },
|
messages = new[] { new { role = "user", content = prompt } },
|
||||||
max_tokens = 400,
|
max_tokens = 600,
|
||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,7 @@ public static class RabbitMqManager
|
||||||
Console.WriteLine("RECEIVED A HEARTBIT FROM prototype, time is "+ WebsocketManager.InstallationConnections[installationId].Timestamp);
|
Console.WriteLine("RECEIVED A HEARTBIT FROM prototype, time is "+ WebsocketManager.InstallationConnections[installationId].Timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
installation.Status = receivedStatusMessage.Status;
|
Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status);
|
||||||
installation.Apply(Db.Update);
|
|
||||||
|
|
||||||
//Console.WriteLine("----------------------------------------------");
|
//Console.WriteLine("----------------------------------------------");
|
||||||
//If the status has changed, update all the connected front-ends regarding this installation
|
//If the status has changed, update all the connected front-ends regarding this installation
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,7 @@ public static class WebsocketManager
|
||||||
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
||||||
|
|
||||||
installationConnection.Value.Status = (int)StatusType.Offline;
|
installationConnection.Value.Status = (int)StatusType.Offline;
|
||||||
Installation installation = Db.Installations.FirstOrDefault(f => f.Product == installationConnection.Value.Product && f.Id == installationConnection.Key);
|
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
||||||
installation.Status = (int)StatusType.Offline;
|
|
||||||
installation.Apply(Db.Update);
|
|
||||||
if (installationConnection.Value.Connections.Count > 0)
|
if (installationConnection.Value.Connections.Count > 0)
|
||||||
{
|
{
|
||||||
idsToInform.Add(installationConnection.Key);
|
idsToInform.Add(installationConnection.Key);
|
||||||
|
|
@ -61,17 +59,31 @@ public static class WebsocketManager
|
||||||
public static async Task InformWebsocketsForInstallation(Int64 installationId)
|
public static async Task InformWebsocketsForInstallation(Int64 installationId)
|
||||||
{
|
{
|
||||||
var installation = Db.GetInstallationById(installationId);
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null) return;
|
||||||
|
|
||||||
byte[] dataToSend;
|
byte[] dataToSend;
|
||||||
List<WebSocket> connections;
|
List<WebSocket> connections;
|
||||||
|
|
||||||
lock (InstallationConnections)
|
lock (InstallationConnections)
|
||||||
{
|
{
|
||||||
var installationConnection = InstallationConnections[installationId];
|
if (!InstallationConnections.ContainsKey(installationId))
|
||||||
Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
|
{
|
||||||
|
Console.WriteLine($"InformWebsocketsForInstallation: No entry for installation {installationId}, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Prune dead/closed connections before sending
|
var installationConnection = InstallationConnections[installationId];
|
||||||
|
|
||||||
|
// Prune dead/closed connections BEFORE checking count
|
||||||
installationConnection.Connections.RemoveAll(c => c.State != WebSocketState.Open);
|
installationConnection.Connections.RemoveAll(c => c.State != WebSocketState.Open);
|
||||||
|
|
||||||
|
if (installationConnection.Connections.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"InformWebsocketsForInstallation: No open connections for installation {installationId}, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
|
||||||
|
|
||||||
var jsonObject = new
|
var jsonObject = new
|
||||||
{
|
{
|
||||||
id = installationId,
|
id = installationId,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-icons-converter": "^1.1.4",
|
"react-icons-converter": "^1.1.4",
|
||||||
"react-intl": "^6.4.4",
|
"react-intl": "^6.4.4",
|
||||||
|
"react-joyride": "^2.9.3",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-router": "6.3.0",
|
"react-router": "6.3.0",
|
||||||
"react-router-dom": "6.3.0",
|
"react-router-dom": "6.3.0",
|
||||||
|
|
@ -2876,6 +2877,11 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@gilbarbara/deep-equal": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.9.5",
|
"version": "0.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
|
||||||
|
|
@ -8195,6 +8201,12 @@
|
||||||
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-diff": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -8205,7 +8217,6 @@
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -11465,6 +11476,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
||||||
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g=="
|
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/is-lite": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="
|
||||||
|
},
|
||||||
"node_modules/is-module": {
|
"node_modules/is-module": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||||
|
|
@ -15414,6 +15430,16 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/popper.js": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
||||||
|
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
|
|
@ -17273,6 +17299,41 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/react-floater": {
|
||||||
|
"version": "0.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
|
||||||
|
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"is-lite": "^0.8.2",
|
||||||
|
"popper.js": "^1.16.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"tree-changes": "^0.9.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "15 - 18",
|
||||||
|
"react-dom": "15 - 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-floater/node_modules/@gilbarbara/deep-equal": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA=="
|
||||||
|
},
|
||||||
|
"node_modules/react-floater/node_modules/is-lite": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw=="
|
||||||
|
},
|
||||||
|
"node_modules/react-floater/node_modules/tree-changes": {
|
||||||
|
"version": "0.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
|
||||||
|
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.1.1",
|
||||||
|
"is-lite": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-flow-renderer": {
|
"node_modules/react-flow-renderer": {
|
||||||
"version": "10.3.17",
|
"version": "10.3.17",
|
||||||
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
|
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
|
||||||
|
|
@ -17392,6 +17453,15 @@
|
||||||
"react": ">=16.3.0"
|
"react": ">=16.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-innertext": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=0.0.0 <=99",
|
||||||
|
"react": ">=0.0.0 <=99"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-intl": {
|
"node_modules/react-intl": {
|
||||||
"version": "6.6.8",
|
"version": "6.6.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
|
||||||
|
|
@ -17423,6 +17493,44 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/react-joyride": {
|
||||||
|
"version": "2.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
|
||||||
|
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
|
"deep-diff": "^1.0.2",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"is-lite": "^1.2.1",
|
||||||
|
"react-floater": "^0.7.9",
|
||||||
|
"react-innertext": "^1.1.5",
|
||||||
|
"react-is": "^16.13.1",
|
||||||
|
"scroll": "^3.0.1",
|
||||||
|
"scrollparent": "^2.1.0",
|
||||||
|
"tree-changes": "^0.11.2",
|
||||||
|
"type-fest": "^4.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "15 - 18",
|
||||||
|
"react-dom": "15 - 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-joyride/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
|
},
|
||||||
|
"node_modules/react-joyride/node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
||||||
|
|
@ -18263,6 +18371,16 @@
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/scroll": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="
|
||||||
|
},
|
||||||
|
"node_modules/scrollparent": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
||||||
|
},
|
||||||
"node_modules/select-hose": {
|
"node_modules/select-hose": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||||
|
|
@ -19771,6 +19889,15 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tree-changes": {
|
||||||
|
"version": "0.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
|
||||||
|
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
|
"is-lite": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tryer": {
|
"node_modules/tryer": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
|
||||||
|
|
@ -23174,6 +23301,11 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@gilbarbara/deep-equal": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="
|
||||||
|
},
|
||||||
"@humanwhocodes/config-array": {
|
"@humanwhocodes/config-array": {
|
||||||
"version": "0.9.5",
|
"version": "0.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
|
||||||
|
|
@ -27055,6 +27187,11 @@
|
||||||
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"deep-diff": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg=="
|
||||||
|
},
|
||||||
"deep-is": {
|
"deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -27064,8 +27201,7 @@
|
||||||
"deepmerge": {
|
"deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"default-gateway": {
|
"default-gateway": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
|
|
@ -29458,6 +29594,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
||||||
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g=="
|
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g=="
|
||||||
},
|
},
|
||||||
|
"is-lite": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="
|
||||||
|
},
|
||||||
"is-module": {
|
"is-module": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||||
|
|
@ -32446,6 +32587,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"popper.js": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
|
||||||
|
},
|
||||||
"possible-typed-array-names": {
|
"possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
|
|
@ -33627,6 +33773,39 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
||||||
},
|
},
|
||||||
|
"react-floater": {
|
||||||
|
"version": "0.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
|
||||||
|
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
|
||||||
|
"requires": {
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"is-lite": "^0.8.2",
|
||||||
|
"popper.js": "^1.16.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"tree-changes": "^0.9.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA=="
|
||||||
|
},
|
||||||
|
"is-lite": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw=="
|
||||||
|
},
|
||||||
|
"tree-changes": {
|
||||||
|
"version": "0.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
|
||||||
|
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
|
||||||
|
"requires": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.1.1",
|
||||||
|
"is-lite": "^0.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-flow-renderer": {
|
"react-flow-renderer": {
|
||||||
"version": "10.3.17",
|
"version": "10.3.17",
|
||||||
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
|
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
|
||||||
|
|
@ -33725,6 +33904,12 @@
|
||||||
"react": ">=16.3.0"
|
"react": ">=16.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-innertext": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"react-intl": {
|
"react-intl": {
|
||||||
"version": "6.6.8",
|
"version": "6.6.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
|
||||||
|
|
@ -33747,6 +33932,36 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||||
},
|
},
|
||||||
|
"react-joyride": {
|
||||||
|
"version": "2.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
|
||||||
|
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
|
||||||
|
"requires": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
|
"deep-diff": "^1.0.2",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"is-lite": "^1.2.1",
|
||||||
|
"react-floater": "^0.7.9",
|
||||||
|
"react-innertext": "^1.1.5",
|
||||||
|
"react-is": "^16.13.1",
|
||||||
|
"scroll": "^3.0.1",
|
||||||
|
"scrollparent": "^2.1.0",
|
||||||
|
"tree-changes": "^0.11.2",
|
||||||
|
"type-fest": "^4.27.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
|
},
|
||||||
|
"type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-redux": {
|
"react-redux": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
||||||
|
|
@ -34334,6 +34549,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scroll": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="
|
||||||
|
},
|
||||||
|
"scrollparent": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
||||||
|
},
|
||||||
"select-hose": {
|
"select-hose": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||||
|
|
@ -35520,6 +35745,15 @@
|
||||||
"punycode": "^2.1.1"
|
"punycode": "^2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"tree-changes": {
|
||||||
|
"version": "0.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
|
||||||
|
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
|
||||||
|
"requires": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
|
"is-lite": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"tryer": {
|
"tryer": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-icons-converter": "^1.1.4",
|
"react-icons-converter": "^1.1.4",
|
||||||
"react-intl": "^6.4.4",
|
"react-intl": "^6.4.4",
|
||||||
|
"react-joyride": "^2.9.3",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-router": "6.3.0",
|
"react-router": "6.3.0",
|
||||||
"react-router-dom": "6.3.0",
|
"react-router-dom": "6.3.0",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import AccessContextProvider from './contexts/AccessContextProvider';
|
||||||
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
|
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
|
||||||
import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations';
|
import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations';
|
||||||
import { ProductIdContext } from './contexts/ProductIdContextProvider';
|
import { ProductIdContext } from './contexts/ProductIdContextProvider';
|
||||||
|
import { TourProvider } from './contexts/TourContext';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
|
|
@ -127,6 +128,11 @@ function App() {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<IntlProvider
|
||||||
|
messages={getTranslations()}
|
||||||
|
locale={language}
|
||||||
|
defaultLocale="en"
|
||||||
|
>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
|
|
@ -143,6 +149,7 @@ function App() {
|
||||||
element={<Navigate to={routes.login}></Navigate>}
|
element={<Navigate to={routes.login}></Navigate>}
|
||||||
></Route>
|
></Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</IntlProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +170,7 @@ function App() {
|
||||||
locale={language}
|
locale={language}
|
||||||
defaultLocale="en"
|
defaultLocale="en"
|
||||||
>
|
>
|
||||||
|
<TourProvider>
|
||||||
<InstallationsContextProvider>
|
<InstallationsContextProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -237,6 +245,7 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</InstallationsContextProvider>
|
</InstallationsContextProvider>
|
||||||
|
</TourProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const axiosConfig = axios.create({
|
||||||
axiosConfig.defaults.params = {};
|
axiosConfig.defaults.params = {};
|
||||||
axiosConfig.interceptors.request.use(
|
axiosConfig.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const tokenString = localStorage.getItem('token');
|
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||||
const token = tokenString !== null ? tokenString : '';
|
const token = tokenString !== null ? tokenString : '';
|
||||||
if (token) {
|
if (token) {
|
||||||
config.params['authToken'] = token;
|
config.params['authToken'] = token;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import routes from 'src/Resources/routes.json';
|
import routes from 'src/Resources/routes.json';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
interface ForgotPasswordPromps {
|
interface ForgotPasswordPromps {
|
||||||
resetPassword: () => void;
|
resetPassword: () => void;
|
||||||
|
|
@ -29,6 +30,7 @@ function ForgotPassword() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
|
|
||||||
|
|
@ -105,7 +107,7 @@ function ForgotPassword() {
|
||||||
<LockOutlinedIcon />
|
<LockOutlinedIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography component="h1" variant="h5">
|
<Typography component="h1" variant="h5">
|
||||||
Provide your username
|
<FormattedMessage id="provideYourUsername" defaultMessage="Provide your username" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="form"
|
component="form"
|
||||||
|
|
@ -118,7 +120,7 @@ function ForgotPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
label="User Name"
|
label={intl.formatMessage({ id: 'userName', defaultMessage: 'User Name' })}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
type="username"
|
type="username"
|
||||||
value={username}
|
value={username}
|
||||||
|
|
@ -150,7 +152,7 @@ function ForgotPassword() {
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Submit
|
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -176,7 +178,7 @@ function ForgotPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Username is wrong. Please try again.
|
<FormattedMessage id="usernameWrong" defaultMessage="Username is wrong. Please try again." />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -188,7 +190,7 @@ function ForgotPassword() {
|
||||||
}}
|
}}
|
||||||
onClick={() => setErrorModalOpen(false)}
|
onClick={() => setErrorModalOpen(false)}
|
||||||
>
|
>
|
||||||
Close
|
<FormattedMessage id="close" defaultMessage="Close" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -216,7 +218,7 @@ function ForgotPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Mail sent successfully.
|
<FormattedMessage id="mailSentSuccessfully" defaultMessage="Mail sent successfully." />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -228,7 +230,7 @@ function ForgotPassword() {
|
||||||
}}
|
}}
|
||||||
onClick={handleReturn}
|
onClick={handleReturn}
|
||||||
>
|
>
|
||||||
Close
|
<FormattedMessage id="close" defaultMessage="Close" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ import { TokenContext } from 'src/contexts/tokenContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Avatar from '@mui/material/Avatar';
|
import Avatar from '@mui/material/Avatar';
|
||||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
function ResetPassword() {
|
function ResetPassword() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -102,7 +103,7 @@ function ResetPassword() {
|
||||||
<LockOutlinedIcon />
|
<LockOutlinedIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography component="h1" variant="h5">
|
<Typography component="h1" variant="h5">
|
||||||
Reset Password
|
<FormattedMessage id="resetPassword" defaultMessage="Reset Password" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="form"
|
component="form"
|
||||||
|
|
@ -115,7 +116,7 @@ function ResetPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
label="Password"
|
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|
@ -126,7 +127,7 @@ function ResetPassword() {
|
||||||
sx={{ width: 350 }}
|
sx={{ width: 350 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Verify Password"
|
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
|
||||||
type="password"
|
type="password"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={verifypassword}
|
value={verifypassword}
|
||||||
|
|
@ -147,7 +148,7 @@ function ResetPassword() {
|
||||||
variant="h5"
|
variant="h5"
|
||||||
sx={{ color: '#FF0000', marginTop: 1 }}
|
sx={{ color: '#FF0000', marginTop: 1 }}
|
||||||
>
|
>
|
||||||
Passwords do not match
|
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -164,7 +165,7 @@ function ResetPassword() {
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Submit
|
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -190,7 +191,7 @@ function ResetPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Reset Password failed. Please try again.
|
<FormattedMessage id="resetPasswordFailed" defaultMessage="Reset Password failed. Please try again." />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -202,7 +203,7 @@ function ResetPassword() {
|
||||||
}}
|
}}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Close
|
<FormattedMessage id="close" defaultMessage="Close" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ import { TokenContext } from 'src/contexts/tokenContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Avatar from '@mui/material/Avatar';
|
import Avatar from '@mui/material/Avatar';
|
||||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
function SetNewPassword() {
|
function SetNewPassword() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -103,7 +104,7 @@ function SetNewPassword() {
|
||||||
<LockOutlinedIcon />
|
<LockOutlinedIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography component="h1" variant="h5">
|
<Typography component="h1" variant="h5">
|
||||||
Set New Password
|
<FormattedMessage id="setNewPassword" defaultMessage="Set New Password" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="form"
|
component="form"
|
||||||
|
|
@ -116,7 +117,7 @@ function SetNewPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
label="Password"
|
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|
@ -127,7 +128,7 @@ function SetNewPassword() {
|
||||||
sx={{ width: 350 }}
|
sx={{ width: 350 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Verify Password"
|
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
|
||||||
type="password"
|
type="password"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={verifypassword}
|
value={verifypassword}
|
||||||
|
|
@ -148,7 +149,7 @@ function SetNewPassword() {
|
||||||
variant="h5"
|
variant="h5"
|
||||||
sx={{ color: '#FF0000', marginTop: 1 }}
|
sx={{ color: '#FF0000', marginTop: 1 }}
|
||||||
>
|
>
|
||||||
Passwords do not match
|
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -165,7 +166,7 @@ function SetNewPassword() {
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Submit
|
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -191,7 +192,7 @@ function SetNewPassword() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Setting new password failed. Please try again.
|
<FormattedMessage id="setNewPasswordFailed" defaultMessage="Setting new password failed. Please try again." />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -203,7 +204,7 @@ function SetNewPassword() {
|
||||||
}}
|
}}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Close
|
<FormattedMessage id="close" defaultMessage="Close" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import inescologo from 'src/Resources/Logo.svg';
|
import inescologo from 'src/Resources/Logo.svg';
|
||||||
import { axiosConfigWithoutToken } from 'src/Resources/axiosConfig';
|
import { axiosConfigWithoutToken } from 'src/Resources/axiosConfig';
|
||||||
import Cookies from 'universal-cookie';
|
|
||||||
import { UserContext } from 'src/contexts/userContext';
|
import { UserContext } from 'src/contexts/userContext';
|
||||||
import { TokenContext } from 'src/contexts/tokenContext';
|
import { TokenContext } from 'src/contexts/tokenContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -25,6 +24,7 @@ import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||||
import routes from 'src/Resources/routes.json';
|
import routes from 'src/Resources/routes.json';
|
||||||
import { ProductIdContext } from '../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../contexts/ProductIdContextProvider';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
|
|
@ -34,6 +34,7 @@ function Login() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
const {
|
const {
|
||||||
|
|
@ -52,7 +53,6 @@ function Login() {
|
||||||
const { currentUser, setUser, removeUser } = context;
|
const { currentUser, setUser, removeUser } = context;
|
||||||
const tokencontext = useContext(TokenContext);
|
const tokencontext = useContext(TokenContext);
|
||||||
const { token, setNewToken, removeToken } = tokencontext;
|
const { token, setNewToken, removeToken } = tokencontext;
|
||||||
const cookies = new Cookies();
|
|
||||||
|
|
||||||
const handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setUsername(event.target.value);
|
setUsername(event.target.value);
|
||||||
|
|
@ -78,19 +78,14 @@ function Login() {
|
||||||
if (response.data && response.data.token) {
|
if (response.data && response.data.token) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
setNewToken(response.data.token);
|
setNewToken(response.data.token, rememberMe);
|
||||||
setUser(response.data.user);
|
setUser(response.data.user, rememberMe);
|
||||||
|
|
||||||
setAccessToSalimax(response.data.accessToSalimax);
|
setAccessToSalimax(response.data.accessToSalimax);
|
||||||
setAccessToSalidomo(response.data.accessToSalidomo);
|
setAccessToSalidomo(response.data.accessToSalidomo);
|
||||||
setAccessToSodiohome(response.data.accessToSodioHome);
|
setAccessToSodiohome(response.data.accessToSodioHome);
|
||||||
setAccessToSodistore(response.data.accessToSodistoreMax);
|
setAccessToSodistore(response.data.accessToSodistoreMax);
|
||||||
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
||||||
|
|
||||||
if (rememberMe) {
|
|
||||||
cookies.set('rememberedUsername', username, { path: '/' });
|
|
||||||
cookies.set('rememberedPassword', password, { path: '/' });
|
|
||||||
}
|
|
||||||
if (response.data.accessToSalimax) {
|
if (response.data.accessToSalimax) {
|
||||||
navigate(routes.installations);
|
navigate(routes.installations);
|
||||||
} else if (response.data.accessToSalidomo) {
|
} else if (response.data.accessToSalidomo) {
|
||||||
|
|
@ -147,7 +142,7 @@ function Login() {
|
||||||
<LockOutlinedIcon />
|
<LockOutlinedIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography component="h1" variant="h5">
|
<Typography component="h1" variant="h5">
|
||||||
Sign in
|
<FormattedMessage id="signIn" defaultMessage="Sign in" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="form"
|
component="form"
|
||||||
|
|
@ -160,7 +155,7 @@ function Login() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
label="Username"
|
label={intl.formatMessage({ id: 'username', defaultMessage: 'Username' })}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={handleUsernameChange}
|
onChange={handleUsernameChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -176,7 +171,7 @@ function Login() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Password"
|
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|
@ -203,7 +198,7 @@ function Login() {
|
||||||
style={{ marginLeft: -175 }}
|
style={{ marginLeft: -175 }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Remember me"
|
label={<FormattedMessage id="rememberMe" defaultMessage="Remember me" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -218,7 +213,7 @@ function Login() {
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Login
|
<FormattedMessage id="login" defaultMessage="Login" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|
@ -253,7 +248,7 @@ function Login() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Login failed. Please try again.
|
<FormattedMessage id="loginFailed" defaultMessage="Login failed. Please try again." />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -265,7 +260,7 @@ function Login() {
|
||||||
}}
|
}}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Close
|
<FormattedMessage id="close" defaultMessage="Close" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -281,7 +276,7 @@ function Login() {
|
||||||
onForgotPassword();
|
onForgotPassword();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Forgot password?
|
<FormattedMessage id="forgotPasswordLink" defaultMessage="Forgot password?" />
|
||||||
</Link>
|
</Link>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { Step } from 'react-joyride';
|
||||||
|
import { IntlShape } from 'react-intl';
|
||||||
|
|
||||||
|
// --- Build a single step with i18n ---
|
||||||
|
|
||||||
|
function makeStep(
|
||||||
|
intl: IntlShape,
|
||||||
|
target: string,
|
||||||
|
titleId: string,
|
||||||
|
contentId: string,
|
||||||
|
placement: Step['placement'] = 'bottom',
|
||||||
|
disableBeacon = false
|
||||||
|
): Step {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
title: intl.formatMessage({ id: titleId }),
|
||||||
|
content: intl.formatMessage({ id: contentId }),
|
||||||
|
placement,
|
||||||
|
...(disableBeacon ? { disableBeacon: true } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tab key → i18n key mapping ---
|
||||||
|
|
||||||
|
const tabConfig: Record<string, { titleId: string; contentId: string }> = {
|
||||||
|
list: { titleId: 'tourListTitle', contentId: 'tourListContent' },
|
||||||
|
tree: { titleId: 'tourTreeTitle', contentId: 'tourTreeContent' },
|
||||||
|
live: { titleId: 'tourLiveTitle', contentId: 'tourLiveContent' },
|
||||||
|
overview: { titleId: 'tourOverviewTitle', contentId: 'tourOverviewContent' },
|
||||||
|
batteryview: { titleId: 'tourBatteryviewTitle', contentId: 'tourBatteryviewContent' },
|
||||||
|
pvview: { titleId: 'tourPvviewTitle', contentId: 'tourPvviewContent' },
|
||||||
|
log: { titleId: 'tourLogTitle', contentId: 'tourLogContent' },
|
||||||
|
information: { titleId: 'tourInformationTitle', contentId: 'tourInformationContent' },
|
||||||
|
report: { titleId: 'tourReportTitle', contentId: 'tourReportContent' },
|
||||||
|
manage: { titleId: 'tourManageTitle', contentId: 'tourManageContent' },
|
||||||
|
configuration: { titleId: 'tourConfigurationTitle', contentId: 'tourConfigurationContent' },
|
||||||
|
history: { titleId: 'tourHistoryTitle', contentId: 'tourHistoryContent' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Steps to skip inside a specific installation (already covered in the list-page tour)
|
||||||
|
const listPageOnlyTabs = new Set(['list', 'tree']);
|
||||||
|
|
||||||
|
// --- Build tour steps from tab value list ---
|
||||||
|
|
||||||
|
function buildTourSteps(intl: IntlShape, tabValues: string[], includeInstallationHint = false, isInsideInstallation = false): Step[] {
|
||||||
|
const steps: Step[] = [];
|
||||||
|
if (!isInsideInstallation) {
|
||||||
|
steps.push(makeStep(intl, '[data-tour="language-selector"]', 'tourLanguageTitle', 'tourLanguageContent', 'bottom', true));
|
||||||
|
}
|
||||||
|
for (const value of tabValues) {
|
||||||
|
if (isInsideInstallation && listPageOnlyTabs.has(value)) continue;
|
||||||
|
const cfg = tabConfig[value];
|
||||||
|
if (cfg) {
|
||||||
|
steps.push(makeStep(intl, `#tour-tab-${value}`, cfg.titleId, cfg.contentId, 'bottom', steps.length === 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (includeInstallationHint && !isInsideInstallation) {
|
||||||
|
steps.push(makeStep(intl, '#tour-tab-list', 'tourExploreTitle', 'tourExploreContent'));
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sodistore Home (product 2) ---
|
||||||
|
|
||||||
|
export const buildSodiohomeCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'live', 'overview', 'information', 'report'
|
||||||
|
], false, inside);
|
||||||
|
|
||||||
|
export const buildSodiohomePartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'information', 'report'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
export const buildSodiohomeAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'manage', 'information', 'configuration', 'history', 'report'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
// --- Salimax (product 0) / Sodistore Max (product 3) ---
|
||||||
|
|
||||||
|
export const buildSalimaxCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'live', 'overview', 'information'
|
||||||
|
], false, inside);
|
||||||
|
|
||||||
|
export const buildSalimaxPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'live', 'overview', 'batteryview', 'pvview', 'information'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
export const buildSalimaxAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history', 'pvview'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
// --- Sodistore Grid (product 4) — same as Salimax but no PV View ---
|
||||||
|
|
||||||
|
export const buildSodistoregridCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'live', 'overview', 'information'
|
||||||
|
], false, inside);
|
||||||
|
|
||||||
|
export const buildSodistoregridPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'live', 'overview', 'batteryview', 'information'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
export const buildSodistoregridAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
// --- Salidomo (product 1) ---
|
||||||
|
|
||||||
|
export const buildSalidomoCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'batteryview', 'overview', 'information'
|
||||||
|
], false, inside);
|
||||||
|
|
||||||
|
export const buildSalidomoPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'batteryview', 'overview', 'information'
|
||||||
|
], true, inside);
|
||||||
|
|
||||||
|
export const buildSalidomoAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||||
|
'list', 'tree', 'batteryview', 'overview', 'log', 'manage', 'information', 'history'
|
||||||
|
], true, inside);
|
||||||
|
|
@ -87,10 +87,10 @@ function BatteryView(props: BatteryViewProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Unable to communicate with the installation
|
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -111,10 +111,10 @@ function BatteryView(props: BatteryViewProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Battery service is not available at the moment
|
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -229,24 +229,24 @@ function BatteryView(props: BatteryViewProps) {
|
||||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="center">Battery</TableCell>
|
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
|
||||||
<TableCell align="center">Firmware</TableCell>
|
<TableCell align="center"><FormattedMessage id="firmware" defaultMessage="Firmware" /></TableCell>
|
||||||
<TableCell align="center">Power</TableCell>
|
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||||
<TableCell align="center">Battery Voltage</TableCell>
|
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
|
||||||
<TableCell align="center">SoC</TableCell>
|
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||||
<TableCell align="center">Temperature</TableCell>
|
<TableCell align="center"><FormattedMessage id="temperature" defaultMessage="Temperature" /></TableCell>
|
||||||
{product === 0 ? (
|
{product === 0 ? (
|
||||||
<TableCell align="center">Warnings</TableCell>
|
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
|
||||||
) : (
|
) : (
|
||||||
<TableCell align="center">Min Cell Voltage</TableCell>
|
<TableCell align="center"><FormattedMessage id="minCellVoltage" defaultMessage="Min Cell Voltage" /></TableCell>
|
||||||
)}
|
)}
|
||||||
{product === 0 ? (
|
{product === 0 ? (
|
||||||
<TableCell align="center">Alarms</TableCell>
|
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
|
||||||
) : (
|
) : (
|
||||||
<TableCell align="center">Max Cell Voltage</TableCell>
|
<TableCell align="center"><FormattedMessage id="maxCellVoltage" defaultMessage="Max Cell Voltage" /></TableCell>
|
||||||
)}
|
)}
|
||||||
{(product === 3 || product === 4) && (
|
{(product === 3 || product === 4) && (
|
||||||
<TableCell align="center">Voltage Difference</TableCell>
|
<TableCell align="center"><FormattedMessage id="voltageDifference" defaultMessage="Voltage Difference" /></TableCell>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Unable to communicate with the installation
|
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -109,10 +109,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Battery service is not available at the moment
|
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -209,14 +209,14 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
||||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="center">Battery</TableCell>
|
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
|
||||||
<TableCell align="center">Firmware</TableCell>
|
<TableCell align="center"><FormattedMessage id="firmware" defaultMessage="Firmware" /></TableCell>
|
||||||
<TableCell align="center">Power</TableCell>
|
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||||
<TableCell align="center">Voltage</TableCell>
|
<TableCell align="center"><FormattedMessage id="voltage" defaultMessage="Voltage" /></TableCell>
|
||||||
<TableCell align="center">SoC</TableCell>
|
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||||
<TableCell align="center">Temperature</TableCell>
|
<TableCell align="center"><FormattedMessage id="temperature" defaultMessage="Temperature" /></TableCell>
|
||||||
<TableCell align="center">Warnings</TableCell>
|
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
|
||||||
<TableCell align="center">Alarms</TableCell>
|
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
|
||||||
|
|
@ -38,20 +38,41 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const inverter = (props.values as any)?.InverterRecord;
|
const inverter = (props.values as any)?.InverterRecord;
|
||||||
const batteryClusterNumber = props.installation.batteryClusterNumber;
|
const batteryClusterNumber = props.installation.batteryClusterNumber;
|
||||||
|
|
||||||
|
const hasDevices = !!inverter?.Devices;
|
||||||
|
|
||||||
const sortedBatteryView = inverter
|
const sortedBatteryView = inverter
|
||||||
? Array.from({ length: batteryClusterNumber }, (_, i) => {
|
? Array.from({ length: batteryClusterNumber }, (_, i) => {
|
||||||
const index = i + 1; // Battery1, Battery2, ...
|
if (hasDevices) {
|
||||||
|
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
|
||||||
|
const deviceId = String(Math.floor(i / 2) + 1);
|
||||||
|
const batteryIndex = (i % 2) + 1;
|
||||||
|
const device = inverter.Devices[deviceId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
BatteryId: String(i + 1),
|
||||||
|
battery: {
|
||||||
|
Voltage: device?.[`Battery${batteryIndex}PackTotalVoltage`] ?? 0,
|
||||||
|
Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0,
|
||||||
|
Power: device?.[`Battery${batteryIndex}Power`] ?? 0,
|
||||||
|
Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0,
|
||||||
|
Soh: device?.[`Battery${batteryIndex}Soh`] ?? 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Growatt: flat Battery1, Battery2, ...
|
||||||
|
const index = i + 1;
|
||||||
return {
|
return {
|
||||||
BatteryId: String(index),
|
BatteryId: String(index),
|
||||||
battery: {
|
battery: {
|
||||||
Voltage: inverter[`Battery${index}Voltage`],
|
Voltage: inverter[`Battery${index}Voltage`] ?? 0,
|
||||||
Current: inverter[`Battery${index}Current`],
|
Current: inverter[`Battery${index}Current`] ?? 0,
|
||||||
Power: inverter[`Battery${index}Power`],
|
Power: inverter[`Battery${index}Power`] ?? 0,
|
||||||
Soc: inverter[`Battery${index}Soc`],
|
Soc: inverter[`Battery${index}Soc`] ?? 0,
|
||||||
Soh: inverter[`Battery${index}Soh`],
|
Soh: inverter[`Battery${index}Soh`] ?? 0,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
@ -87,10 +108,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Unable to communicate with the installation
|
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -111,10 +132,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Battery service is not available at the moment
|
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -195,12 +216,12 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
||||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="center">Battery</TableCell>
|
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
|
||||||
<TableCell align="center">Power</TableCell>
|
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||||
<TableCell align="center">Battery Voltage</TableCell>
|
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
|
||||||
<TableCell align="center">Current</TableCell>
|
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
|
||||||
<TableCell align="center">SoC</TableCell>
|
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||||
<TableCell align="center">SoH</TableCell>
|
<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>
|
||||||
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
|
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
|
||||||
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
|
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
||||||
|
|
@ -634,66 +634,66 @@ function DetailedBatteryViewSodistore(
|
||||||
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
||||||
{/*Temperature List*/}
|
{/*Temperature List*/}
|
||||||
|
|
||||||
<Card
|
{/*<Card*/}
|
||||||
sx={{
|
{/* sx={{*/}
|
||||||
overflow: 'visible',
|
{/* overflow: 'visible',*/}
|
||||||
marginTop: '20px',
|
{/* marginTop: '20px',*/}
|
||||||
marginLeft: '20px',
|
{/* marginLeft: '20px',*/}
|
||||||
display: 'flex',
|
{/* display: 'flex',*/}
|
||||||
flexDirection: 'column',
|
{/* flexDirection: 'column',*/}
|
||||||
alignItems: 'center',
|
{/* alignItems: 'center',*/}
|
||||||
border: '2px solid #ccc',
|
{/* border: '2px solid #ccc',*/}
|
||||||
borderRadius: '12px',
|
{/* borderRadius: '12px',*/}
|
||||||
height: '270px'
|
{/* height: '270px'*/}
|
||||||
}}
|
{/* }}*/}
|
||||||
>
|
{/*>*/}
|
||||||
<Typography
|
{/* <Typography*/}
|
||||||
variant="h6"
|
{/* variant="h6"*/}
|
||||||
component="div"
|
{/* component="div"*/}
|
||||||
sx={{
|
{/* sx={{*/}
|
||||||
marginTop: '10px',
|
{/* marginTop: '10px',*/}
|
||||||
borderBottom: '1px solid #ccc',
|
{/* borderBottom: '1px solid #ccc',*/}
|
||||||
fontWeight: 'bold'
|
{/* fontWeight: 'bold'*/}
|
||||||
}}
|
{/* }}*/}
|
||||||
>
|
{/* >*/}
|
||||||
Battery Temperatures
|
{/* Battery Temperatures*/}
|
||||||
</Typography>
|
{/* </Typography>*/}
|
||||||
|
|
||||||
<TableContainer
|
{/* <TableContainer*/}
|
||||||
component={Paper}
|
{/* component={Paper}*/}
|
||||||
sx={{
|
{/* sx={{*/}
|
||||||
marginTop: '20px',
|
{/* marginTop: '20px',*/}
|
||||||
width: '100%',
|
{/* width: '100%',*/}
|
||||||
maxHeight: '270px', // scrolling threshold
|
{/* maxHeight: '270px',*/}
|
||||||
overflowY: 'auto'
|
{/* overflowY: 'auto'*/}
|
||||||
}}
|
{/* }}*/}
|
||||||
>
|
{/* >*/}
|
||||||
<Table size="medium" stickyHeader aria-label="temperature table">
|
{/* <Table size="medium" stickyHeader aria-label="temperature table">*/}
|
||||||
<TableBody>
|
{/* <TableBody>*/}
|
||||||
{Object.entries(
|
{/* {Object.entries(*/}
|
||||||
props.batteryData.BatteryDeligreenDataRecord
|
{/* props.batteryData.BatteryDeligreenDataRecord*/}
|
||||||
.TemperaturesList || {}
|
{/* .TemperaturesList || {}*/}
|
||||||
).map(([label, value]) => (
|
{/* ).map(([label, value]) => (*/}
|
||||||
<TableRow key={label}>
|
{/* <TableRow key={label}>*/}
|
||||||
<TableCell align="left" sx={{ fontWeight: 'bold' }}>
|
{/* <TableCell align="left" sx={{ fontWeight: 'bold' }}>*/}
|
||||||
{label}
|
{/* {label}*/}
|
||||||
</TableCell>
|
{/* </TableCell>*/}
|
||||||
<TableCell
|
{/* <TableCell*/}
|
||||||
align="right"
|
{/* align="right"*/}
|
||||||
sx={{
|
{/* sx={{*/}
|
||||||
width: '6ch',
|
{/* width: '6ch',*/}
|
||||||
whiteSpace: 'nowrap',
|
{/* whiteSpace: 'nowrap',*/}
|
||||||
paddingRight: '12px'
|
{/* paddingRight: '12px'*/}
|
||||||
}}
|
{/* }}*/}
|
||||||
>
|
{/* >*/}
|
||||||
{value + ' °C'}
|
{/* {value + ' °C'}*/}
|
||||||
</TableCell>
|
{/* </TableCell>*/}
|
||||||
</TableRow>
|
{/* </TableRow>*/}
|
||||||
))}
|
{/* ))}*/}
|
||||||
</TableBody>
|
{/* </TableBody>*/}
|
||||||
</Table>
|
{/* </Table>*/}
|
||||||
</TableContainer>
|
{/* </TableContainer>*/}
|
||||||
</Card>
|
{/*</Card>*/}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@ function MainStats(props: MainStatsProps) {
|
||||||
>
|
>
|
||||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||||
Fetching data...
|
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ function MainStatsSalidomo(props: MainStatsProps) {
|
||||||
>
|
>
|
||||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||||
Fetching data...
|
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
||||||
>
|
>
|
||||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||||
Fetching data...
|
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { Close as CloseIcon } from '@mui/icons-material';
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
|
@ -40,6 +40,7 @@ interface ConfigurationProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Configuration(props: ConfigurationProps) {
|
function Configuration(props: ConfigurationProps) {
|
||||||
|
const intl = useIntl();
|
||||||
if (props.values === null) {
|
if (props.values === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +138,7 @@ function Configuration(props: ConfigurationProps) {
|
||||||
props.values.EssControl.Mode === 'CalibrationCharge'
|
props.values.EssControl.Mode === 'CalibrationCharge'
|
||||||
) {
|
) {
|
||||||
setDateSelectionError(
|
setDateSelectionError(
|
||||||
'You cannot change the date while the installation is in Calibration Charge Mode'
|
intl.formatMessage({ id: 'cannotChangeDateCalibration' })
|
||||||
);
|
);
|
||||||
setErrorDateModalOpen(true);
|
setErrorDateModalOpen(true);
|
||||||
return;
|
return;
|
||||||
|
|
@ -146,7 +147,7 @@ function Configuration(props: ConfigurationProps) {
|
||||||
dayjs(formValues.calibrationChargeDate).isBefore(dayjs())
|
dayjs(formValues.calibrationChargeDate).isBefore(dayjs())
|
||||||
) {
|
) {
|
||||||
//console.log('asked for', dayjs(formValues.calibrationChargeDate));
|
//console.log('asked for', dayjs(formValues.calibrationChargeDate));
|
||||||
setDateSelectionError('You must specify a future date');
|
setDateSelectionError(intl.formatMessage({ id: 'mustSpecifyFutureDate' }));
|
||||||
setErrorDateModalOpen(true);
|
setErrorDateModalOpen(true);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -458,7 +459,7 @@ function Configuration(props: ConfigurationProps) {
|
||||||
helperText={
|
helperText={
|
||||||
errors.minimumSoC ? (
|
errors.minimumSoC ? (
|
||||||
<span style={{ color: 'red' }}>
|
<span style={{ color: 'red' }}>
|
||||||
Value should be between 0-100%
|
{intl.formatMessage({ id: 'valueBetween0And100' })}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|
@ -592,7 +593,7 @@ function Configuration(props: ConfigurationProps) {
|
||||||
helperText={
|
helperText={
|
||||||
errors.gridSetPoint ? (
|
errors.gridSetPoint ? (
|
||||||
<span style={{ color: 'red' }}>
|
<span style={{ color: 'red' }}>
|
||||||
Please provide a valid number
|
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|
@ -804,7 +805,7 @@ function Configuration(props: ConfigurationProps) {
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Successfully applied configuration file
|
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -824,7 +825,7 @@ function Configuration(props: ConfigurationProps) {
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
An error has occurred
|
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ import timezone from 'dayjs/plugin/timezone';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { UserContext } from '../../../contexts/userContext';
|
import { UserContext } from '../../../contexts/userContext';
|
||||||
|
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
||||||
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
@ -58,6 +60,8 @@ function HistoryOfActions(props: HistoryProps) {
|
||||||
});
|
});
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
const { currentUser, setUser } = context;
|
const { currentUser, setUser } = context;
|
||||||
|
const { fetchAllInstallations } = useContext(InstallationsContext);
|
||||||
|
const { product } = useContext(ProductIdContext);
|
||||||
const [isRowHovered, setHoveredRow] = useState(-1);
|
const [isRowHovered, setHoveredRow] = useState(-1);
|
||||||
const [selectedAction, setSelectedAction] = useState<number>(-1);
|
const [selectedAction, setSelectedAction] = useState<number>(-1);
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
@ -109,6 +113,7 @@ function HistoryOfActions(props: HistoryProps) {
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
getHistory();
|
getHistory();
|
||||||
|
fetchAllInstallations(product, false);
|
||||||
setOpenModalAddAction(false);
|
setOpenModalAddAction(false);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +134,7 @@ function HistoryOfActions(props: HistoryProps) {
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
getHistory();
|
getHistory();
|
||||||
|
fetchAllInstallations(product, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
|
@ -14,13 +15,14 @@ import {
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { Close as CloseIcon } from '@mui/icons-material';
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import { I_S3Credentials } from '../../../interfaces/S3Types';
|
import { I_S3Credentials } from '../../../interfaces/S3Types';
|
||||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||||
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
||||||
import { UserContext } from '../../../contexts/userContext';
|
import { UserContext } from '../../../contexts/userContext';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
|
import axiosConfig from '../../../Resources/axiosConfig';
|
||||||
|
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
@ -53,6 +55,24 @@ function Information(props: InformationProps) {
|
||||||
deleteInstallation
|
deleteInstallation
|
||||||
} = installationContext;
|
} = installationContext;
|
||||||
|
|
||||||
|
const canEdit = currentUser.userType == UserType.admin;
|
||||||
|
const isPartner = currentUser.userType == UserType.partner;
|
||||||
|
const isSodistore = formValues.product === 3 || formValues.product === 4;
|
||||||
|
|
||||||
|
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
|
||||||
|
const [loadingProviders, setLoadingProviders] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSodistore) {
|
||||||
|
setLoadingProviders(true);
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetNetworkProviders')
|
||||||
|
.then((res) => setNetworkProviders(res.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingProviders(false));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormValues({
|
setFormValues({
|
||||||
|
|
@ -286,6 +306,54 @@ function Information(props: InformationProps) {
|
||||||
error={formValues.country === ''}
|
error={formValues.country === ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isSodistore && (
|
||||||
|
<div>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={networkProviders}
|
||||||
|
value={formValues.networkProvider || ''}
|
||||||
|
onChange={(_e, val) =>
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
networkProvider: (val as string) || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onInputChange={(_e, val) =>
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
networkProvider: val || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canEdit && !isPartner}
|
||||||
|
loading={loadingProviders}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="networkProvider"
|
||||||
|
defaultMessage="Network Provider"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loadingProviders ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={
|
||||||
|
|
@ -341,7 +409,7 @@ function Information(props: InformationProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentUser.userType == UserType.admin && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -400,7 +468,7 @@ function Information(props: InformationProps) {
|
||||||
marginTop: 10
|
marginTop: 10
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentUser.userType == UserType.admin && (
|
{canEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|
@ -414,7 +482,7 @@ function Information(props: InformationProps) {
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentUser.userType == UserType.admin && (
|
{(canEdit || (isPartner && isSodistore)) && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
|
@ -26,6 +27,7 @@ import { UserContext } from '../../../contexts/userContext';
|
||||||
import routes from '../../../Resources/routes.json';
|
import routes from '../../../Resources/routes.json';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
|
import axiosConfig from '../../../Resources/axiosConfig';
|
||||||
|
|
||||||
interface InformationSodistorehomeProps {
|
interface InformationSodistorehomeProps {
|
||||||
values: I_Installation;
|
values: I_Installation;
|
||||||
|
|
@ -178,6 +180,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
const canEdit = currentUser.userType === UserType.admin;
|
const canEdit = currentUser.userType === UserType.admin;
|
||||||
const isPartner = currentUser.userType === UserType.partner;
|
const isPartner = currentUser.userType === UserType.partner;
|
||||||
|
|
||||||
|
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
|
||||||
|
const [loadingProviders, setLoadingProviders] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingProviders(true);
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetNetworkProviders')
|
||||||
|
.then((res) => setNetworkProviders(res.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingProviders(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{openModalDeleteInstallation && (
|
{openModalDeleteInstallation && (
|
||||||
|
|
@ -361,6 +375,52 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={networkProviders}
|
||||||
|
value={formValues.networkProvider || ''}
|
||||||
|
onChange={(_e, val) =>
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
networkProvider: (val as string) || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onInputChange={(_e, val) =>
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
networkProvider: val || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canEdit && !isPartner}
|
||||||
|
loading={loadingProviders}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="networkProvider"
|
||||||
|
defaultMessage="Network Provider"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loadingProviders ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import routes from '../../../Resources/routes.json';
|
import routes from '../../../Resources/routes.json';
|
||||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
|
|
@ -33,6 +33,7 @@ interface FlatInstallationViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
const [isRowHovered, setHoveredRow] = useState(-1);
|
const [isRowHovered, setHoveredRow] = useState(-1);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||||
|
|
@ -202,7 +203,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
>
|
>
|
||||||
<FormControl sx={{ flex: 1 }}>
|
<FormControl sx={{ flex: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search"
|
placeholder={intl.formatMessage({ id: 'search' })}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -226,7 +227,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
<Select
|
<Select
|
||||||
value={sortByStatus}
|
value={sortByStatus}
|
||||||
onChange={(e) => setSortByStatus(e.target.value)}
|
onChange={(e) => setSortByStatus(e.target.value)}
|
||||||
label="Show Only"
|
label={intl.formatMessage({ id: 'showOnly' })}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
'All Installations',
|
'All Installations',
|
||||||
|
|
@ -252,7 +253,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
<Select
|
<Select
|
||||||
value={sortByAction}
|
value={sortByAction}
|
||||||
onChange={(e) => setSortByAction(e.target.value)}
|
onChange={(e) => setSortByAction(e.target.value)}
|
||||||
label="Show Only"
|
label={intl.formatMessage({ id: 'showOnly' })}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
'All Installations',
|
'All Installations',
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ function Installation(props: singleInstallationProps) {
|
||||||
currentTab == 'live' ||
|
currentTab == 'live' ||
|
||||||
currentTab == 'pvview' ||
|
currentTab == 'pvview' ||
|
||||||
currentTab == 'configuration' ||
|
currentTab == 'configuration' ||
|
||||||
|
currentTab == 'overview' ||
|
||||||
location.includes('batteryview')
|
location.includes('batteryview')
|
||||||
) {
|
) {
|
||||||
//Fetch periodically if the tab is live, pvview or batteryview
|
//Fetch periodically if the tab is live, pvview or batteryview
|
||||||
|
|
@ -217,6 +218,10 @@ function Installation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fetch one time in overview tab to determine connectivity
|
||||||
|
if (currentTab == 'overview') {
|
||||||
|
fetchDataForOneTime();
|
||||||
|
}
|
||||||
//Fetch only one time in configuration tab
|
//Fetch only one time in configuration tab
|
||||||
if (currentTab == 'configuration') {
|
if (currentTab == 'configuration') {
|
||||||
fetchDataForOneTime();
|
fetchDataForOneTime();
|
||||||
|
|
@ -376,7 +381,6 @@ function Installation(props: singleInstallationProps) {
|
||||||
currentTab != 'information' &&
|
currentTab != 'information' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'overview' &&
|
|
||||||
currentTab != 'log' && (
|
currentTab != 'log' && (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="xl"
|
maxWidth="xl"
|
||||||
|
|
@ -394,7 +398,7 @@ function Installation(props: singleInstallationProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Connecting to the device...
|
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -447,6 +451,8 @@ function Installation(props: singleInstallationProps) {
|
||||||
<Overview
|
<Overview
|
||||||
s3Credentials={s3Credentials}
|
s3Credentials={s3Credentials}
|
||||||
id={props.current_installation.id}
|
id={props.current_installation.id}
|
||||||
|
connected={connected}
|
||||||
|
loading={loading}
|
||||||
></Overview>
|
></Overview>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,66 @@ import { S3Access } from 'src/dataCache/S3/S3Access';
|
||||||
import { JSONRecordData, parseChunkJson } from '../Log/graph.util';
|
import { JSONRecordData, parseChunkJson } from '../Log/graph.util';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
|
// Find the latest chunk file in S3 using ListObjects (single HTTP request)
|
||||||
|
// Returns the parsed chunk data or FetchResult.notAvailable
|
||||||
|
export const fetchLatestDataJson = (
|
||||||
|
s3Credentials?: I_S3Credentials,
|
||||||
|
maxAgeSeconds: number = 400
|
||||||
|
): Promise<FetchResult<Record<string, JSONRecordData>>> => {
|
||||||
|
if (!s3Credentials || !s3Credentials.s3Bucket) {
|
||||||
|
return Promise.resolve(FetchResult.notAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Access = new S3Access(
|
||||||
|
s3Credentials.s3Bucket,
|
||||||
|
s3Credentials.s3Region,
|
||||||
|
s3Credentials.s3Provider,
|
||||||
|
s3Credentials.s3Key,
|
||||||
|
s3Credentials.s3Secret
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use marker to skip files older than maxAgeSeconds
|
||||||
|
const oldestTimestamp = Math.floor(Date.now() / 1000) - maxAgeSeconds;
|
||||||
|
const marker = `${oldestTimestamp}.json`;
|
||||||
|
|
||||||
|
return s3Access
|
||||||
|
.list(marker, 50)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (r.status !== 200) {
|
||||||
|
return Promise.resolve(FetchResult.notAvailable);
|
||||||
|
}
|
||||||
|
const xml = await r.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xml, 'application/xml');
|
||||||
|
const keys = Array.from(doc.getElementsByTagName('Key'))
|
||||||
|
.map((el) => el.textContent)
|
||||||
|
.filter((key) => key && /^\d+\.json$/.test(key))
|
||||||
|
.sort((a, b) => Number(b.replace('.json', '')) - Number(a.replace('.json', '')));
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return Promise.resolve(FetchResult.notAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the most recent chunk file
|
||||||
|
const latestKey = keys[0];
|
||||||
|
const res = await s3Access.get(latestKey);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return Promise.resolve(FetchResult.notAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsontext = await res.text();
|
||||||
|
const byteArray = Uint8Array.from(atob(jsontext), (c) =>
|
||||||
|
c.charCodeAt(0)
|
||||||
|
);
|
||||||
|
const zip = await JSZip.loadAsync(byteArray);
|
||||||
|
const jsonContent = await zip.file('data.json').async('text');
|
||||||
|
return parseChunkJson(jsonContent);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
return Promise.resolve(FetchResult.tryLater);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchDataJson = (
|
export const fetchDataJson = (
|
||||||
timestamp: UnixTime,
|
timestamp: UnixTime,
|
||||||
s3Credentials?: I_S3Credentials,
|
s3Credentials?: I_S3Credentials,
|
||||||
|
|
@ -50,7 +110,8 @@ export const fetchDataJson = (
|
||||||
|
|
||||||
export const fetchAggregatedDataJson = (
|
export const fetchAggregatedDataJson = (
|
||||||
date: string,
|
date: string,
|
||||||
s3Credentials?: I_S3Credentials
|
s3Credentials?: I_S3Credentials,
|
||||||
|
product?: number
|
||||||
): Promise<FetchResult<any>> => {
|
): Promise<FetchResult<any>> => {
|
||||||
const s3Path = `${date}.json`;
|
const s3Path = `${date}.json`;
|
||||||
|
|
||||||
|
|
@ -68,7 +129,12 @@ export const fetchAggregatedDataJson = (
|
||||||
if (r.status === 404) {
|
if (r.status === 404) {
|
||||||
return Promise.resolve(FetchResult.notAvailable);
|
return Promise.resolve(FetchResult.notAvailable);
|
||||||
} else if (r.status === 200) {
|
} else if (r.status === 200) {
|
||||||
const jsontext = await r.text(); // Assuming the server returns the Base64 encoded ZIP file as text
|
const jsontext = await r.text();
|
||||||
|
|
||||||
|
if (product === 2) {
|
||||||
|
return parseSinexcelAggregatedData(jsontext);
|
||||||
|
}
|
||||||
|
|
||||||
const contentEncoding = r.headers.get('content-type');
|
const contentEncoding = r.headers.get('content-type');
|
||||||
|
|
||||||
if (contentEncoding != 'application/base64; charset=utf-8') {
|
if (contentEncoding != 'application/base64; charset=utf-8') {
|
||||||
|
|
@ -82,7 +148,6 @@ export const fetchAggregatedDataJson = (
|
||||||
const zip = await JSZip.loadAsync(byteArray);
|
const zip = await JSZip.loadAsync(byteArray);
|
||||||
// Assuming the CSV file is named "data.csv" inside the ZIP archive
|
// Assuming the CSV file is named "data.csv" inside the ZIP archive
|
||||||
const jsonContent = await zip.file('data.json').async('text');
|
const jsonContent = await zip.file('data.json').async('text');
|
||||||
//console.log(jsonContent);
|
|
||||||
return JSON.parse(jsonContent);
|
return JSON.parse(jsonContent);
|
||||||
} else {
|
} else {
|
||||||
return Promise.resolve(FetchResult.notAvailable);
|
return Promise.resolve(FetchResult.notAvailable);
|
||||||
|
|
@ -94,6 +159,24 @@ export const fetchAggregatedDataJson = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseSinexcelAggregatedData = (jsontext: string): any => {
|
||||||
|
const lines = jsontext.trim().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.Type === 'Daily') {
|
||||||
|
return {
|
||||||
|
PvPower: entry.DailySelfGeneratedElectricity ?? 0,
|
||||||
|
GridImportPower: entry.DailyElectricityPurchased ?? 0,
|
||||||
|
GridExportPower: -(entry.DailyElectricityFed ?? 0),
|
||||||
|
ChargingBatteryPower: entry.BatteryDailyChargeEnergy ?? 0,
|
||||||
|
DischargingBatteryPower: -(entry.BatteryDailyDischargeEnergy ?? 0),
|
||||||
|
LoadPowerConsumption: entry.DailyLoadPowerConsumption ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FetchResult.notAvailable;
|
||||||
|
};
|
||||||
|
|
||||||
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
? routes[tab.value]
|
? routes[tab.value]
|
||||||
: navigateToTabPath(location.pathname, routes[tab.value])
|
: navigateToTabPath(location.pathname, routes[tab.value])
|
||||||
}
|
}
|
||||||
|
id={`tour-tab-${tab.value}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -480,6 +481,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
component={Link}
|
component={Link}
|
||||||
label={tab.label}
|
label={tab.label}
|
||||||
to={routes[tab.value]}
|
to={routes[tab.value]}
|
||||||
|
id={`tour-tab-${tab.value}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,8 @@ function Log(props: LogProps) {
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
<Grid container>
|
<Grid container>
|
||||||
|
|
||||||
{/* ── AI Diagnosis Demo Panel ── */}
|
{/* ── AI Diagnosis Demo Panel (commented out — using live AI diagnosis only) ── */}
|
||||||
|
{/*
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Box sx={{ marginTop: '20px' }}>
|
<Box sx={{ marginTop: '20px' }}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -355,6 +356,7 @@ function Log(props: LogProps) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
|
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
|
||||||
{diagnosisLoading && (
|
{diagnosisLoading && (
|
||||||
|
|
|
||||||
|
|
@ -437,23 +437,76 @@ export interface JSONRecordData {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// For SodistoreHome
|
// For SodistoreHome (Sinexcel multi-inverter structure)
|
||||||
InverterRecord: {
|
InverterRecord: {
|
||||||
|
// Top-level aggregated values
|
||||||
|
TotalPhotovoltaicPower: number;
|
||||||
|
TotalBatteryPower: number;
|
||||||
|
TotalLoadPower: number;
|
||||||
|
TotalGridPower: number;
|
||||||
|
AvgBatteryVoltage: number;
|
||||||
|
TotalBatteryCurrent: number;
|
||||||
|
AvgBatterySoc: number;
|
||||||
|
AvgBatterySoh: number;
|
||||||
|
AvgBatteryTemp: number;
|
||||||
|
OperatingPriority?: string;
|
||||||
|
MinSoc: number;
|
||||||
|
MaxChargeCurrent: number;
|
||||||
|
MaxDischargingCurrent: number;
|
||||||
GridPower: number;
|
GridPower: number;
|
||||||
|
GridFrequency: number;
|
||||||
|
InverterPower: number;
|
||||||
|
EnableGridExport?: string;
|
||||||
|
GridExportPower: number;
|
||||||
|
// Legacy flat fields (Growatt compatibility)
|
||||||
|
Battery1Power?: number;
|
||||||
|
Battery1Soc?: number;
|
||||||
|
Battery1Soh?: number;
|
||||||
|
Battery1Voltage?: number;
|
||||||
|
Battery1Current?: number;
|
||||||
|
Battery2Power?: number;
|
||||||
|
Battery2Soc?: number;
|
||||||
|
Battery2Voltage?: number;
|
||||||
|
Battery2Current?: number;
|
||||||
|
Battery2Soh?: number;
|
||||||
|
PvPower?: number;
|
||||||
|
ConsumptionPower?: number;
|
||||||
|
WorkingMode?: string;
|
||||||
|
OperatingMode?: string;
|
||||||
|
PvTotalPower?: number;
|
||||||
|
Battery1AmbientTemperature?: number;
|
||||||
|
Battery1Temperature?: number;
|
||||||
|
// Per-device records (Sinexcel multi-inverter)
|
||||||
|
Devices?: {
|
||||||
|
[deviceId: string]: {
|
||||||
Battery1Power: number;
|
Battery1Power: number;
|
||||||
Battery1Soc: number;
|
Battery1Soc: number;
|
||||||
Battery1Soh: number;
|
Battery1Soh: number;
|
||||||
Battery1Voltage: number;
|
Battery1Voltage: number;
|
||||||
Battery1Current: number;
|
Battery1Current: number;
|
||||||
|
Battery1PackTotalVoltage: number;
|
||||||
|
Battery1PackTotalCurrent: number;
|
||||||
|
Battery1Temperature: number;
|
||||||
|
Battery1SocSecondvalue: number;
|
||||||
Battery2Power: number;
|
Battery2Power: number;
|
||||||
Battery2Soc: number;
|
Battery2Soc: number;
|
||||||
|
Battery2Soh: number;
|
||||||
Battery2Voltage: number;
|
Battery2Voltage: number;
|
||||||
Battery2Current: number;
|
Battery2Current: number;
|
||||||
Battery2Soh:number;
|
Battery2PackTotalVoltage: number;
|
||||||
PvPower:number;
|
Battery2PackTotalCurrent: number;
|
||||||
|
Battery2Temperature: number;
|
||||||
|
Battery2Socsecondvalue: number;
|
||||||
ConsumptionPower: number;
|
ConsumptionPower: number;
|
||||||
WorkingMode?:string;
|
TotalPhotovoltaicPower: number;
|
||||||
OperatingMode?:string;
|
TotalBatteryPower: number;
|
||||||
|
TotalLoadPower: number;
|
||||||
|
TotalGridPower: number;
|
||||||
|
GridPower: number;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
AcDcGrowatt: {
|
AcDcGrowatt: {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
transformInputToDailyDataJson
|
transformInputToDailyDataJson
|
||||||
} from 'src/interfaces/Chart';
|
} from 'src/interfaces/Chart';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
|
|
@ -33,6 +33,9 @@ import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
interface OverviewProps {
|
interface OverviewProps {
|
||||||
s3Credentials: I_S3Credentials;
|
s3Credentials: I_S3Credentials;
|
||||||
id: number;
|
id: number;
|
||||||
|
device?: number;
|
||||||
|
connected?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const computeLast7Days = (): string[] => {
|
const computeLast7Days = (): string[] => {
|
||||||
|
|
@ -52,6 +55,7 @@ const computeLast7Days = (): string[] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function Overview(props: OverviewProps) {
|
function Overview(props: OverviewProps) {
|
||||||
|
const intl = useIntl();
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
const { currentUser } = context;
|
const { currentUser } = context;
|
||||||
const [dailyData, setDailyData] = useState(true);
|
const [dailyData, setDailyData] = useState(true);
|
||||||
|
|
@ -102,6 +106,12 @@ function Overview(props: OverviewProps) {
|
||||||
}
|
}
|
||||||
}, [isZooming, dailyDataArray]);
|
}, [isZooming, dailyDataArray]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.connected === false) {
|
||||||
|
setErrorDateModalOpen(false);
|
||||||
|
}
|
||||||
|
}, [props.connected]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resultPromise: Promise<{
|
const resultPromise: Promise<{
|
||||||
chartData: chartDataInterface;
|
chartData: chartDataInterface;
|
||||||
|
|
@ -119,8 +129,6 @@ function Overview(props: OverviewProps) {
|
||||||
resultPromise
|
resultPromise
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.chartData.soc.data.length === 0) {
|
if (result.chartData.soc.data.length === 0) {
|
||||||
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
|
|
||||||
setErrorDateModalOpen(true);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -209,11 +217,19 @@ function Overview(props: OverviewProps) {
|
||||||
}> = transformInputToAggregatedDataJson(
|
}> = transformInputToAggregatedDataJson(
|
||||||
props.s3Credentials,
|
props.s3Credentials,
|
||||||
dayjs().subtract(1, 'week'),
|
dayjs().subtract(1, 'week'),
|
||||||
dayjs()
|
dayjs(),
|
||||||
|
product
|
||||||
);
|
);
|
||||||
|
|
||||||
resultPromise
|
resultPromise
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (result.dateList.length === 0) {
|
||||||
|
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||||
|
setErrorDateModalOpen(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const powerDifference = [];
|
const powerDifference = [];
|
||||||
for (
|
for (
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
@ -288,7 +304,7 @@ function Overview(props: OverviewProps) {
|
||||||
resultPromise
|
resultPromise
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.chartData.soc.data.length === 0) {
|
if (result.chartData.soc.data.length === 0) {
|
||||||
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
|
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||||
setErrorDateModalOpen(true);
|
setErrorDateModalOpen(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -319,11 +335,19 @@ function Overview(props: OverviewProps) {
|
||||||
}> = transformInputToAggregatedDataJson(
|
}> = transformInputToAggregatedDataJson(
|
||||||
props.s3Credentials,
|
props.s3Credentials,
|
||||||
startDate,
|
startDate,
|
||||||
endDate
|
endDate,
|
||||||
|
product
|
||||||
);
|
);
|
||||||
|
|
||||||
resultPromise
|
resultPromise
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (result.dateList.length === 0) {
|
||||||
|
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||||
|
setErrorDateModalOpen(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const powerDifference = [];
|
const powerDifference = [];
|
||||||
|
|
||||||
for (
|
for (
|
||||||
|
|
@ -379,6 +403,23 @@ function Overview(props: OverviewProps) {
|
||||||
const renderGraphs = () => {
|
const renderGraphs = () => {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
|
{!props.connected && !props.loading && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: 'red',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: '10px',
|
||||||
|
marginBottom: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="installationOffline"
|
||||||
|
defaultMessage="Installation is currently offline. Showing last available data."
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{isErrorDateModalOpen && (
|
{isErrorDateModalOpen && (
|
||||||
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
|
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -509,6 +550,7 @@ function Overview(props: OverviewProps) {
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
)}
|
)}
|
||||||
<Grid container>
|
<Grid container>
|
||||||
|
{!props.loading && (props.connected !== false || dailyDataArray.length > 0 || aggregatedDataArray.length > 0) && (<>
|
||||||
<Grid item xs={6} md={6}>
|
<Grid item xs={6} md={6}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|
@ -523,7 +565,7 @@ function Overview(props: OverviewProps) {
|
||||||
>
|
>
|
||||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||||
</Button>
|
</Button>
|
||||||
{product !== 2 && (
|
{props.device !== 3 && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleWeekData}
|
onClick={handleWeekData}
|
||||||
|
|
@ -540,7 +582,6 @@ function Overview(props: OverviewProps) {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*{aggregatedData && (*/}
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSetDate}
|
onClick={handleSetDate}
|
||||||
|
|
@ -555,7 +596,6 @@ function Overview(props: OverviewProps) {
|
||||||
>
|
>
|
||||||
<FormattedMessage id="set_date" defaultMessage="Set Date" />
|
<FormattedMessage id="set_date" defaultMessage="Set Date" />
|
||||||
</Button>
|
</Button>
|
||||||
{/*)}*/}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
|
|
@ -602,6 +642,7 @@ function Overview(props: OverviewProps) {
|
||||||
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Container
|
<Container
|
||||||
|
|
@ -616,7 +657,7 @@ function Overview(props: OverviewProps) {
|
||||||
>
|
>
|
||||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||||
Fetching data...
|
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -767,7 +808,7 @@ function Overview(props: OverviewProps) {
|
||||||
{
|
{
|
||||||
...aggregatedDataArray[aggregatedChartState]
|
...aggregatedDataArray[aggregatedChartState]
|
||||||
.chartData.gridExportPower,
|
.chartData.gridExportPower,
|
||||||
color: '#ff3333',
|
color: '#2e7d32',
|
||||||
type: 'bar'
|
type: 'bar'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -776,13 +817,13 @@ function Overview(props: OverviewProps) {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
color: '#ff9900'
|
color: '#ff9900'
|
||||||
},
|
},
|
||||||
{
|
...(product !== 2 ? [{
|
||||||
name: 'Net Energy',
|
name: 'Net Energy',
|
||||||
color: '#ff3333',
|
color: '#e65100',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: aggregatedDataArray[aggregatedChartState]
|
data: aggregatedDataArray[aggregatedChartState]
|
||||||
.netbalance
|
.netbalance
|
||||||
}
|
}] : [])
|
||||||
]}
|
]}
|
||||||
height={400}
|
height={400}
|
||||||
type={'bar'}
|
type={'bar'}
|
||||||
|
|
@ -799,6 +840,7 @@ function Overview(props: OverviewProps) {
|
||||||
alignItems="stretch"
|
alignItems="stretch"
|
||||||
spacing={3}
|
spacing={3}
|
||||||
>
|
>
|
||||||
|
{!(aggregatedData && product === 2) && (
|
||||||
<Grid item md={6} xs={12}>
|
<Grid item md={6} xs={12}>
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -890,7 +932,8 @@ function Overview(props: OverviewProps) {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={6} xs={12}>
|
)}
|
||||||
|
<Grid item md={(aggregatedData && product === 2) ? 12 : 6} xs={12}>
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
|
|
@ -958,11 +1001,14 @@ function Overview(props: OverviewProps) {
|
||||||
<ReactApexChart
|
<ReactApexChart
|
||||||
options={{
|
options={{
|
||||||
...getChartOptions(
|
...getChartOptions(
|
||||||
aggregatedDataArray[aggregatedChartState]
|
product === 2
|
||||||
|
? aggregatedDataArray[aggregatedChartState]
|
||||||
|
.chartOverview.dcPowerWithoutHeating
|
||||||
|
: aggregatedDataArray[aggregatedChartState]
|
||||||
.chartOverview.dcPower,
|
.chartOverview.dcPower,
|
||||||
'weekly',
|
'weekly',
|
||||||
aggregatedDataArray[aggregatedChartState].datelist,
|
aggregatedDataArray[aggregatedChartState].datelist,
|
||||||
false
|
product === 2
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
series={[
|
series={[
|
||||||
|
|
@ -971,11 +1017,11 @@ function Overview(props: OverviewProps) {
|
||||||
.chartData.dcChargingPower,
|
.chartData.dcChargingPower,
|
||||||
color: '#008FFB'
|
color: '#008FFB'
|
||||||
},
|
},
|
||||||
{
|
...(product !== 2 ? [{
|
||||||
...aggregatedDataArray[aggregatedChartState]
|
...aggregatedDataArray[aggregatedChartState]
|
||||||
.chartData.heatingPower,
|
.chartData.heatingPower,
|
||||||
color: '#ff9900'
|
color: '#ff9900'
|
||||||
},
|
}] : []),
|
||||||
{
|
{
|
||||||
...aggregatedDataArray[aggregatedChartState]
|
...aggregatedDataArray[aggregatedChartState]
|
||||||
.chartData.dcDischargingPower,
|
.chartData.dcDischargingPower,
|
||||||
|
|
@ -1027,7 +1073,8 @@ function Overview(props: OverviewProps) {
|
||||||
alignItems="stretch"
|
alignItems="stretch"
|
||||||
spacing={3}
|
spacing={3}
|
||||||
>
|
>
|
||||||
<Grid item md={product === 2 ? 12 : 6} xs={12}>
|
{product !== 2 && (
|
||||||
|
<Grid item md={6} xs={12}>
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
|
|
@ -1088,6 +1135,7 @@ function Overview(props: OverviewProps) {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
{product !== 2 && (
|
{product !== 2 && (
|
||||||
<Grid item md={6} xs={12}>
|
<Grid item md={6} xs={12}>
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -1344,6 +1392,63 @@ function Overview(props: OverviewProps) {
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{aggregatedData && product === 2 && (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="stretch"
|
||||||
|
spacing={3}
|
||||||
|
>
|
||||||
|
<Grid item md={12} xs={12}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
overflow: 'visible',
|
||||||
|
marginTop: '30px',
|
||||||
|
marginBottom: '30px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginLeft: '20px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" noWrap>
|
||||||
|
<FormattedMessage
|
||||||
|
id="ac_load_aggregated"
|
||||||
|
defaultMessage="AC Load Energy"
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<ReactApexChart
|
||||||
|
options={{
|
||||||
|
...getChartOptions(
|
||||||
|
aggregatedDataArray[aggregatedChartState]
|
||||||
|
.chartOverview.ACLoad,
|
||||||
|
'weekly',
|
||||||
|
aggregatedDataArray[aggregatedChartState].datelist,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
series={[
|
||||||
|
{
|
||||||
|
...aggregatedDataArray[aggregatedChartState]
|
||||||
|
.chartData.acLoad,
|
||||||
|
color: '#ff9900'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
type="bar"
|
||||||
|
height={400}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
{dailyData && (
|
{dailyData && (
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
|
|
@ -1485,6 +1590,10 @@ function Overview(props: OverviewProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (props.loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{renderGraphs()}</>;
|
return <>{renderGraphs()}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import { UserType } from '../../../interfaces/UserTypes';
|
||||||
interface salidomoOverviewProps {
|
interface salidomoOverviewProps {
|
||||||
s3Credentials: I_S3Credentials;
|
s3Credentials: I_S3Credentials;
|
||||||
id: number;
|
id: number;
|
||||||
|
connected?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const computeLast7Days = (): string[] => {
|
const computeLast7Days = (): string[] => {
|
||||||
|
|
@ -405,7 +407,7 @@ const computeLast7Days = (): string[] => {
|
||||||
// >
|
// >
|
||||||
// <CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
// <CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||||
// <Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
// <Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||||
// Fetching data...
|
// <FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||||
// </Typography>
|
// </Typography>
|
||||||
// </Container>
|
// </Container>
|
||||||
// )}
|
// )}
|
||||||
|
|
@ -750,6 +752,23 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
||||||
const renderGraphs = () => {
|
const renderGraphs = () => {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
|
{!props.connected && !props.loading && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: 'red',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: '10px',
|
||||||
|
marginBottom: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="installationOffline"
|
||||||
|
defaultMessage="Installation is currently offline. Showing last available data."
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{isErrorDateModalOpen && (
|
{isErrorDateModalOpen && (
|
||||||
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
|
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -874,6 +893,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
)}
|
)}
|
||||||
<Grid container>
|
<Grid container>
|
||||||
|
{!props.loading && (props.connected !== false || aggregatedDataArray.length > 0) && (<>
|
||||||
<Grid item xs={6} md={6}>
|
<Grid item xs={6} md={6}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|
@ -931,6 +951,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
||||||
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Container
|
<Container
|
||||||
|
|
@ -945,7 +966,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
||||||
>
|
>
|
||||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||||
Fetching data...
|
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1123,6 +1144,10 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (props.loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{renderGraphs()}</>;
|
return <>{renderGraphs()}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { JSONRecordData } from '../Log/graph.util';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import routes from '../../../Resources/routes.json';
|
import routes from '../../../Resources/routes.json';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
interface PvViewProps {
|
interface PvViewProps {
|
||||||
values: JSONRecordData;
|
values: JSONRecordData;
|
||||||
|
|
@ -80,10 +81,10 @@ function PvView(props: PvViewProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Unable to communicate with the installation
|
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -105,10 +106,10 @@ function PvView(props: PvViewProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Pv view is not available at the moment
|
<FormattedMessage id="pvViewNotAvailable" defaultMessage="Pv view is not available at the moment" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -130,10 +131,10 @@ function PvView(props: PvViewProps) {
|
||||||
<Table sx={{ minWidth: 250 }} aria-label={`CU${deviceId} table`}>
|
<Table sx={{ minWidth: 250 }} aria-label={`CU${deviceId} table`}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="center">Pv</TableCell>
|
<TableCell align="center"><FormattedMessage id="pv" defaultMessage="Pv" /></TableCell>
|
||||||
<TableCell align="center">Power</TableCell>
|
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||||
<TableCell align="center">Voltage</TableCell>
|
<TableCell align="center"><FormattedMessage id="voltage" defaultMessage="Voltage" /></TableCell>
|
||||||
<TableCell align="center">Current</TableCell>
|
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import routes from '../../../Resources/routes.json';
|
import routes from '../../../Resources/routes.json';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
|
@ -32,6 +32,7 @@ interface FlatInstallationViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||||
const currentLocation = useLocation();
|
const currentLocation = useLocation();
|
||||||
|
|
@ -182,7 +183,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
>
|
>
|
||||||
<FormControl sx={{ flex: 1 }}>
|
<FormControl sx={{ flex: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search"
|
placeholder={intl.formatMessage({ id: 'search' })}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -206,7 +207,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
<Select
|
<Select
|
||||||
value={sortByStatus}
|
value={sortByStatus}
|
||||||
onChange={(e) => setSortByStatus(e.target.value)}
|
onChange={(e) => setSortByStatus(e.target.value)}
|
||||||
label="Show Only"
|
label={intl.formatMessage({ id: 'showOnly' })}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
'All Installations',
|
'All Installations',
|
||||||
|
|
@ -232,7 +233,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
<Select
|
<Select
|
||||||
value={sortByAction}
|
value={sortByAction}
|
||||||
onChange={(e) => setSortByAction(e.target.value)}
|
onChange={(e) => setSortByAction(e.target.value)}
|
||||||
label="Show Only"
|
label={intl.formatMessage({ id: 'showOnly' })}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
'All Installations',
|
'All Installations',
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,31 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
setCurrentTab(path[path.length - 1]);
|
setCurrentTab(path[path.length - 1]);
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
|
const fetchDataForOneTime = async () => {
|
||||||
|
var timeperiodToSearch = 30;
|
||||||
|
for (var i = 0; i < timeperiodToSearch; i += 1) {
|
||||||
|
var timestampToFetch = UnixTime.now().earlier(TimeSpan.fromMinutes(i));
|
||||||
|
try {
|
||||||
|
var res = await fetchDataJson(timestampToFetch, s3Credentials, true);
|
||||||
|
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
||||||
|
setConnected(true);
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching data:', err);
|
||||||
|
setConnected(false);
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConnected(false);
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.includes('batteryview')) {
|
if (location.includes('batteryview') || currentTab == 'overview') {
|
||||||
if (location.includes('batteryview') && !location.includes('mainstats')) {
|
if (location.includes('batteryview') && !location.includes('mainstats')) {
|
||||||
if (!continueFetching.current) {
|
if (!continueFetching.current) {
|
||||||
continueFetching.current = true;
|
continueFetching.current = true;
|
||||||
|
|
@ -168,6 +191,10 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fetch one time in overview tab to determine connectivity
|
||||||
|
if (currentTab == 'overview') {
|
||||||
|
fetchDataForOneTime();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
continueFetching.current = false;
|
continueFetching.current = false;
|
||||||
|
|
@ -295,7 +322,6 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
</div>
|
</div>
|
||||||
{loading &&
|
{loading &&
|
||||||
currentTab != 'information' &&
|
currentTab != 'information' &&
|
||||||
currentTab != 'overview' &&
|
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'log' && (
|
currentTab != 'log' && (
|
||||||
|
|
@ -315,7 +341,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Connecting to the device...
|
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -357,6 +383,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
<SalidomoOverview
|
<SalidomoOverview
|
||||||
s3Credentials={s3Credentials}
|
s3Credentials={s3Credentials}
|
||||||
id={props.current_installation.id}
|
id={props.current_installation.id}
|
||||||
|
connected={connected}
|
||||||
|
loading={loading}
|
||||||
></SalidomoOverview>
|
></SalidomoOverview>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
? routes[tab.value]
|
? routes[tab.value]
|
||||||
: navigateToTabPath(location.pathname, routes[tab.value])
|
: navigateToTabPath(location.pathname, routes[tab.value])
|
||||||
}
|
}
|
||||||
|
id={`tour-tab-${tab.value}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -349,6 +350,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
component={Link}
|
component={Link}
|
||||||
label={tab.label}
|
label={tab.label}
|
||||||
to={routes[tab.value]}
|
to={routes[tab.value]}
|
||||||
|
id={`tour-tab-${tab.value}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,614 @@
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
} from 'chart.js';
|
||||||
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
import { SavingsCards } from './WeeklyReport';
|
||||||
|
|
||||||
|
Chart.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Interfaces ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DailyEnergyData {
|
||||||
|
date: string;
|
||||||
|
pvProduction: number;
|
||||||
|
loadConsumption: number;
|
||||||
|
gridImport: number;
|
||||||
|
gridExport: number;
|
||||||
|
batteryCharged: number;
|
||||||
|
batteryDischarged: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HourlyEnergyRecord {
|
||||||
|
date: string;
|
||||||
|
hour: number;
|
||||||
|
pvKwh: number;
|
||||||
|
loadKwh: number;
|
||||||
|
gridImportKwh: number;
|
||||||
|
batteryChargedKwh: number;
|
||||||
|
batteryDischargedKwh: number;
|
||||||
|
battSoC: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date Helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor date for the 7-day strip. Returns last completed Sunday.
|
||||||
|
* To switch to live-data mode later, change to: () => new Date()
|
||||||
|
*/
|
||||||
|
function getDataAnchorDate(): Date {
|
||||||
|
const today = new Date();
|
||||||
|
const dow = today.getDay(); // 0=Sun
|
||||||
|
const lastSunday = new Date(today);
|
||||||
|
lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow));
|
||||||
|
lastSunday.setHours(0, 0, 0, 0);
|
||||||
|
return lastSunday;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekRange(anchor: Date): { monday: Date; sunday: Date } {
|
||||||
|
const sunday = new Date(anchor);
|
||||||
|
const monday = new Date(sunday);
|
||||||
|
monday.setDate(sunday.getDate() - 6);
|
||||||
|
return { monday, sunday };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateISO(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekDays(monday: Date): Date[] {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setDate(monday.getDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function DailySection({
|
||||||
|
installationId
|
||||||
|
}: {
|
||||||
|
installationId: number;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const anchor = useMemo(() => getDataAnchorDate(), []);
|
||||||
|
const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]);
|
||||||
|
const weekDays = useMemo(() => getWeekDays(monday), [monday]);
|
||||||
|
|
||||||
|
const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]);
|
||||||
|
const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday));
|
||||||
|
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
|
||||||
|
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
|
const [loadingWeek, setLoadingWeek] = useState(false);
|
||||||
|
const [noData, setNoData] = useState(false);
|
||||||
|
|
||||||
|
// Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingWeek(true);
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetDailyDetailRecords', {
|
||||||
|
params: {
|
||||||
|
installationId,
|
||||||
|
from: formatDateISO(monday),
|
||||||
|
to: formatDateISO(sunday)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const daily = res.data?.dailyRecords?.records ?? [];
|
||||||
|
const hourly = res.data?.hourlyRecords?.records ?? [];
|
||||||
|
setWeekRecords(Array.isArray(daily) ? daily : []);
|
||||||
|
setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setWeekRecords([]);
|
||||||
|
setWeekHourlyRecords([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingWeek(false));
|
||||||
|
}, [installationId, monday, sunday]);
|
||||||
|
|
||||||
|
// When selected date changes, extract data from week cache or fetch
|
||||||
|
useEffect(() => {
|
||||||
|
setNoData(false);
|
||||||
|
setSelectedDayRecord(null);
|
||||||
|
|
||||||
|
// Try week cache first
|
||||||
|
const cachedDay = weekRecords.find((r) => r.date === selectedDate);
|
||||||
|
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
|
||||||
|
|
||||||
|
if (cachedDay) {
|
||||||
|
setSelectedDayRecord(cachedDay);
|
||||||
|
setHourlyRecords(cachedHours);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache (date picker outside strip) — fetch via combined endpoint
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetDailyDetailRecords', {
|
||||||
|
params: { installationId, from: selectedDate, to: selectedDate }
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const daily = res.data?.dailyRecords?.records ?? [];
|
||||||
|
const hourly = res.data?.hourlyRecords?.records ?? [];
|
||||||
|
setHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
||||||
|
if (Array.isArray(daily) && daily.length > 0) {
|
||||||
|
setSelectedDayRecord(daily[0]);
|
||||||
|
} else {
|
||||||
|
setNoData(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setHourlyRecords([]);
|
||||||
|
setNoData(true);
|
||||||
|
});
|
||||||
|
}, [installationId, selectedDate, weekRecords, weekHourlyRecords]);
|
||||||
|
|
||||||
|
const record = selectedDayRecord;
|
||||||
|
|
||||||
|
const kpis = useMemo(() => computeKPIs(record), [record]);
|
||||||
|
|
||||||
|
const handleDatePicker = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedDate(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStripSelect = (date: string) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setNoData(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dt = new Date(selectedDate + 'T00:00:00');
|
||||||
|
const dateLabel = dt.toLocaleDateString(intl.locale, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Date Picker */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
<Typography variant="body1" fontWeight="bold">
|
||||||
|
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={handleDatePicker}
|
||||||
|
inputProps={{ max: formatDateISO(new Date()) }}
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 7-Day Strip */}
|
||||||
|
<DayStrip
|
||||||
|
weekDays={weekDays}
|
||||||
|
weekRecords={weekRecords}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onSelect={handleStripSelect}
|
||||||
|
sunday={sunday}
|
||||||
|
loading={loadingWeek}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{loadingWeek && !record && (
|
||||||
|
<Container
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '20vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No data state */}
|
||||||
|
{!loadingWeek && noData && !record && (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="noDataForDate"
|
||||||
|
defaultMessage="No data available for the selected date."
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day detail */}
|
||||||
|
{record && (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<Paper
|
||||||
|
sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
<FormattedMessage
|
||||||
|
id="dailyReportTitle"
|
||||||
|
defaultMessage="Daily Energy Summary"
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.8, mt: 0.5 }}>
|
||||||
|
{dateLabel}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<SavingsCards
|
||||||
|
intl={intl}
|
||||||
|
energySaved={+kpis.energySaved.toFixed(1)}
|
||||||
|
savingsCHF={kpis.savingsCHF}
|
||||||
|
selfSufficiency={kpis.selfSufficiency}
|
||||||
|
batteryEfficiency={kpis.batteryEfficiency}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Intraday Chart */}
|
||||||
|
<IntradayChart
|
||||||
|
hourlyData={hourlyRecords}
|
||||||
|
loading={loadingWeek}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Summary Table */}
|
||||||
|
<DailySummaryTable record={record} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KPI Computation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function computeKPIs(record: DailyEnergyData | null) {
|
||||||
|
if (!record) {
|
||||||
|
return { energySaved: 0, savingsCHF: 0, selfSufficiency: 0, batteryEfficiency: 0 };
|
||||||
|
}
|
||||||
|
const energySaved = Math.max(0, record.loadConsumption - record.gridImport);
|
||||||
|
const savingsCHF = +(energySaved * 0.39).toFixed(2);
|
||||||
|
const selfSufficiency =
|
||||||
|
record.loadConsumption > 0
|
||||||
|
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
|
||||||
|
: 0;
|
||||||
|
const batteryEfficiency =
|
||||||
|
record.batteryCharged > 0
|
||||||
|
? Math.min(100, Math.floor((record.batteryDischarged / record.batteryCharged) * 100))
|
||||||
|
: 0;
|
||||||
|
return { energySaved, savingsCHF, selfSufficiency, batteryEfficiency };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DayStrip ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DayStrip({
|
||||||
|
weekDays,
|
||||||
|
weekRecords,
|
||||||
|
selectedDate,
|
||||||
|
onSelect,
|
||||||
|
sunday,
|
||||||
|
loading
|
||||||
|
}: {
|
||||||
|
weekDays: Date[];
|
||||||
|
weekRecords: DailyEnergyData[];
|
||||||
|
selectedDate: string;
|
||||||
|
onSelect: (date: string) => void;
|
||||||
|
sunday: Date;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const sundayLabel = sunday.toLocaleDateString(intl.locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
overflowX: 'auto',
|
||||||
|
pb: 1,
|
||||||
|
mb: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dateStr = formatDateISO(day);
|
||||||
|
const isSelected = dateStr === selectedDate;
|
||||||
|
const record = weekRecords.find((r) => r.date === dateStr);
|
||||||
|
const selfSuff =
|
||||||
|
record && record.loadConsumption > 0
|
||||||
|
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={dateStr}
|
||||||
|
onClick={() => onSelect(dateStr)}
|
||||||
|
elevation={isSelected ? 4 : 1}
|
||||||
|
sx={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: 80,
|
||||||
|
p: 1.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: isSelected ? '2px solid #2980b9' : '2px solid transparent',
|
||||||
|
bgcolor: isSelected ? '#e3f2fd' : '#fff',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
'&:hover': { bgcolor: isSelected ? '#e3f2fd' : '#f5f5f5' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight="bold" sx={{ color: '#666' }}>
|
||||||
|
{day.toLocaleDateString(intl.locale, { weekday: 'short' })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
|
||||||
|
{day.getDate()}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: selfSuff != null ? '#8e44ad' : '#ccc' }}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '...'
|
||||||
|
: selfSuff != null
|
||||||
|
? `${selfSuff.toFixed(0)}%`
|
||||||
|
: '—'}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="dataUpTo"
|
||||||
|
defaultMessage="Data up to {date}"
|
||||||
|
values={{ date: sundayLabel }}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IntradayChart ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HOUR_LABELS = Array.from({ length: 24 }, (_, i) =>
|
||||||
|
`${String(i).padStart(2, '0')}:00`
|
||||||
|
);
|
||||||
|
|
||||||
|
function IntradayChart({
|
||||||
|
hourlyData,
|
||||||
|
loading
|
||||||
|
}: {
|
||||||
|
hourlyData: HourlyEnergyRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4, mb: 3 }}>
|
||||||
|
<CircularProgress size={30} style={{ color: '#ffc04d' }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hourlyData.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="noHourlyData"
|
||||||
|
defaultMessage="Hourly data not available for this day."
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
|
||||||
|
|
||||||
|
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null);
|
||||||
|
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null);
|
||||||
|
const batteryData = HOUR_LABELS.map((_, i) => {
|
||||||
|
const h = hourMap.get(i);
|
||||||
|
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
|
||||||
|
});
|
||||||
|
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: HOUR_LABELS,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: 'pvProduction', defaultMessage: 'PV Production' }),
|
||||||
|
data: pvData,
|
||||||
|
borderColor: '#f1c40f',
|
||||||
|
backgroundColor: 'rgba(241,196,15,0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' }),
|
||||||
|
data: loadData,
|
||||||
|
borderColor: '#e74c3c',
|
||||||
|
backgroundColor: 'rgba(231,76,60,0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: 'batteryPower', defaultMessage: 'Battery Power' }),
|
||||||
|
data: batteryData,
|
||||||
|
borderColor: '#3498db',
|
||||||
|
backgroundColor: 'rgba(52,152,219,0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: 'batterySoCLabel', defaultMessage: 'Battery SoC' }),
|
||||||
|
data: socData,
|
||||||
|
borderColor: '#27ae60',
|
||||||
|
borderDash: [6, 3],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
yAxisID: 'soc'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
position: 'left' as const,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: intl.formatMessage({ id: 'powerKw', defaultMessage: 'Power (kW)' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
soc: {
|
||||||
|
position: 'right' as const,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: intl.formatMessage({ id: 'socPercent', defaultMessage: 'SoC (%)' })
|
||||||
|
},
|
||||||
|
grid: { drawOnChartArea: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="intradayChart"
|
||||||
|
defaultMessage="Intraday Power Flow"
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ height: 350 }}>
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DailySummaryTable ────────────────────────────────────────
|
||||||
|
|
||||||
|
function DailySummaryTable({ record }: { record: DailyEnergyData }) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
<FormattedMessage id="dailySummary" defaultMessage="Daily Summary" />
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="table"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
'& td, & th': { p: 1.5, borderBottom: '1px solid #eee' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8f9fa' }}>
|
||||||
|
<th style={{ textAlign: 'left' }}>
|
||||||
|
<FormattedMessage id="metric" defaultMessage="Metric" />
|
||||||
|
</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>
|
||||||
|
<FormattedMessage id="total" defaultMessage="Total" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
|
||||||
|
{record.pvProduction.toFixed(1)} kWh
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
|
||||||
|
{record.loadConsumption.toFixed(1)} kWh
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
|
||||||
|
{record.gridImport.toFixed(1)} kWh
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
|
||||||
|
{record.gridExport.toFixed(1)} kWh
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: '#f0f7ff' }}>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<FormattedMessage id="batteryCharged" defaultMessage="Battery Charged" />
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#2980b9' }}>
|
||||||
|
{record.batteryCharged.toFixed(1)} kWh
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: '#f0f7ff' }}>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<FormattedMessage
|
||||||
|
id="batteryDischarged"
|
||||||
|
defaultMessage="Battery Discharged"
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#e67e22' }}>
|
||||||
|
{record.batteryDischarged.toFixed(1)} kWh
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -111,20 +111,52 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
return btoa(String.fromCharCode(...combined));
|
return btoa(String.fromCharCode(...combined));
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchDataPeriodically = async () => {
|
// Probe multiple timestamps in parallel, return first successful result
|
||||||
var timeperiodToSearch = 350;
|
const probeTimestampBatch = async (
|
||||||
let res;
|
offsets: number[]
|
||||||
let timestampToFetch;
|
): Promise<{ res: any; timestamp: UnixTime } | null> => {
|
||||||
|
const now = UnixTime.now();
|
||||||
|
const promises = offsets.map(async (offset) => {
|
||||||
|
const ts = now.earlier(TimeSpan.fromSeconds(offset));
|
||||||
|
const result = await fetchDataJson(ts, s3Credentials, false);
|
||||||
|
if (result !== FetchResult.notAvailable && result !== FetchResult.tryLater) {
|
||||||
|
return { res: result, timestamp: ts };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
for (var i = 0; i < timeperiodToSearch; i += 30) {
|
const results = await Promise.all(promises);
|
||||||
|
// Return the most recent hit (smallest offset = first in array)
|
||||||
|
return results.find((r) => r !== null) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDataPeriodically = async () => {
|
||||||
|
let res;
|
||||||
|
let timestampToFetch: UnixTime;
|
||||||
|
|
||||||
|
// Search backward in parallel batches of 10 timestamps (2s apart)
|
||||||
|
// Each batch covers 20 seconds, so 20 batches cover 400 seconds
|
||||||
|
const batchSize = 10;
|
||||||
|
const step = 2; // 2-second steps to match even-rounding granularity
|
||||||
|
const maxAge = 400;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let batchStart = 0; batchStart < maxAge; batchStart += batchSize * step) {
|
||||||
if (!continueFetching.current) {
|
if (!continueFetching.current) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
const offsets = [];
|
||||||
|
for (let j = 0; j < batchSize; j++) {
|
||||||
|
const offset = batchStart + j * step;
|
||||||
|
if (offset < maxAge) offsets.push(offset);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
|
const hit = await probeTimestampBatch(offsets);
|
||||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
if (hit) {
|
||||||
|
res = hit.res;
|
||||||
|
timestampToFetch = hit.timestamp;
|
||||||
|
found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -133,7 +165,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i >= timeperiodToSearch) {
|
if (!found) {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -154,10 +186,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
await timeout(2000);
|
await timeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(60));
|
// Advance by 150s to find the next chunk (15 records × 10s interval)
|
||||||
|
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(150));
|
||||||
console.log('NEW TIMESTAMP TO FETCH IS ' + timestampToFetch);
|
console.log('NEW TIMESTAMP TO FETCH IS ' + timestampToFetch);
|
||||||
|
|
||||||
for (i = 0; i < 30; i++) {
|
let foundNext = false;
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
if (!continueFetching.current) {
|
if (!continueFetching.current) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +203,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
res !== FetchResult.notAvailable &&
|
res !== FetchResult.notAvailable &&
|
||||||
res !== FetchResult.tryLater
|
res !== FetchResult.tryLater
|
||||||
) {
|
) {
|
||||||
|
foundNext = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -177,24 +212,30 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(1));
|
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(1));
|
||||||
}
|
}
|
||||||
if (i == 30) {
|
if (!foundNext) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDataForOneTime = async () => {
|
const fetchDataForOneTime = async () => {
|
||||||
var timeperiodToSearch = 300; // 5 minutes to cover ~2 upload cycles (150s each)
|
// Search backward in parallel batches of 10 timestamps (2s apart)
|
||||||
|
const batchSize = 10;
|
||||||
|
const step = 2;
|
||||||
|
const maxAge = 400;
|
||||||
let res;
|
let res;
|
||||||
let timestampToFetch;
|
|
||||||
|
|
||||||
// Search from NOW backward to find the most recent data
|
for (let batchStart = 0; batchStart < maxAge; batchStart += batchSize * step) {
|
||||||
// Step by 50 seconds - data is uploaded every ~150s, so finer steps are wasteful
|
const offsets = [];
|
||||||
for (var i = 0; i < timeperiodToSearch; i += 50) {
|
for (let j = 0; j < batchSize; j++) {
|
||||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
const offset = batchStart + j * step;
|
||||||
|
if (offset < maxAge) offsets.push(offset);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
|
const hit = await probeTimestampBatch(offsets);
|
||||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
if (hit) {
|
||||||
|
res = hit.res;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -203,11 +244,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i >= timeperiodToSearch) {
|
if (!res) {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
|
|
@ -215,12 +257,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
const timestamps = Object.keys(res).sort((a, b) => Number(b) - Number(a));
|
const timestamps = Object.keys(res).sort((a, b) => Number(b) - Number(a));
|
||||||
const latestTimestamp = timestamps[0];
|
const latestTimestamp = timestamps[0];
|
||||||
setValues(res[latestTimestamp]);
|
setValues(res[latestTimestamp]);
|
||||||
// setValues(
|
|
||||||
// extractValues({
|
|
||||||
// time: UnixTime.fromTicks(parseInt(timestamp, 10)),
|
|
||||||
// value: res[timestamp]
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -240,6 +276,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
currentTab == 'live' ||
|
currentTab == 'live' ||
|
||||||
currentTab == 'pvview' ||
|
currentTab == 'pvview' ||
|
||||||
currentTab == 'configuration' ||
|
currentTab == 'configuration' ||
|
||||||
|
currentTab == 'overview' ||
|
||||||
location.includes('batteryview')
|
location.includes('batteryview')
|
||||||
) {
|
) {
|
||||||
//Fetch periodically if the tab is live, pvview or batteryview
|
//Fetch periodically if the tab is live, pvview or batteryview
|
||||||
|
|
@ -257,14 +294,20 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fetch periodically in configuration tab (every 30 seconds to detect S3 updates)
|
// Fetch one time in overview tab to determine connectivity
|
||||||
|
if (currentTab == 'overview') {
|
||||||
|
fetchDataForOneTime();
|
||||||
|
return () => {
|
||||||
|
continueFetching.current = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fetch periodically in configuration tab to detect S3 config updates
|
||||||
if (currentTab == 'configuration') {
|
if (currentTab == 'configuration') {
|
||||||
fetchDataForOneTime(); // Initial fetch
|
fetchDataForOneTime();
|
||||||
|
|
||||||
const configRefreshInterval = setInterval(() => {
|
const configRefreshInterval = setInterval(() => {
|
||||||
console.log('Refreshing configuration data from S3...');
|
|
||||||
fetchDataForOneTime();
|
fetchDataForOneTime();
|
||||||
}, 60000); // Refresh every 60 seconds (data uploads every ~150s)
|
}, 30000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
continueFetching.current = false;
|
continueFetching.current = false;
|
||||||
|
|
@ -340,11 +383,10 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
fontSize: '14px'
|
fontSize: '14px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.current_installation.device === 4
|
{values.InverterRecord?.OperatingPriority
|
||||||
? values.InverterRecord?.WorkingMode
|
?? values.InverterRecord?.WorkingMode
|
||||||
: props.current_installation.device === 3
|
?? values.InverterRecord?.OperatingMode
|
||||||
? values.InverterRecord?.OperatingMode
|
?? values.Config?.OperatingPriority}
|
||||||
: values.Config.OperatingPriority}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -428,7 +470,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
</div>
|
</div>
|
||||||
{loading &&
|
{loading &&
|
||||||
currentTab != 'information' &&
|
currentTab != 'information' &&
|
||||||
currentTab != 'overview' &&
|
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'log' &&
|
currentTab != 'log' &&
|
||||||
|
|
@ -449,7 +490,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Connecting to the device...
|
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -559,6 +600,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
<Overview
|
<Overview
|
||||||
s3Credentials={s3Credentials}
|
s3Credentials={s3Credentials}
|
||||||
id={props.current_installation.id}
|
id={props.current_installation.id}
|
||||||
|
device={props.current_installation.device}
|
||||||
|
connected={connected}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
|
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
|
||||||
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
|
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import routes from '../../../Resources/routes.json';
|
import routes from '../../../Resources/routes.json';
|
||||||
|
|
@ -12,6 +13,7 @@ interface installationSearchProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstallationSearch(props: installationSearchProps) {
|
function InstallationSearch(props: installationSearchProps) {
|
||||||
|
const intl = useIntl();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const currentLocation = useLocation();
|
const currentLocation = useLocation();
|
||||||
// const [filteredData, setFilteredData] = useState(props.installations);
|
// const [filteredData, setFilteredData] = useState(props.installations);
|
||||||
|
|
@ -60,7 +62,7 @@ function InstallationSearch(props: installationSearchProps) {
|
||||||
>
|
>
|
||||||
<FormControl variant="outlined">
|
<FormControl variant="outlined">
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search"
|
placeholder={intl.formatMessage({ id: 'search' })}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import React, { useContext, useState, useEffect } from 'react';
|
import React, { useContext, useState, useEffect } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { Close as CloseIcon } from '@mui/icons-material';
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
|
@ -39,6 +39,7 @@ interface SodistoreHomeConfigurationProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
|
const intl = useIntl();
|
||||||
if (props.values === null) {
|
if (props.values === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -46,20 +47,17 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
const device = props.installation.device;
|
const device = props.installation.device;
|
||||||
|
|
||||||
const OperatingPriorityOptions =
|
const OperatingPriorityOptions =
|
||||||
device === 3 // Growatt
|
device === 3 || device === 4
|
||||||
? ['LoadPriority', 'BatteryPriority', 'GridPriority']
|
? ['LoadPriority', 'BatteryPriority', 'GridPriority']
|
||||||
: device === 4 // Sinexcel
|
|
||||||
? [
|
|
||||||
'SpontaneousSelfUse',
|
|
||||||
'TimeChargeDischarge',
|
|
||||||
// 'TimeOfUsePowerPrice',
|
|
||||||
// 'DisasterStandby',
|
|
||||||
// 'ManualControl',
|
|
||||||
'PvPriorityCharging',
|
|
||||||
// 'PrioritySellElectricity'
|
|
||||||
]
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Sinexcel S3 stores WorkingMode enum names — map them to Growatt-style display names
|
||||||
|
const sinexcelS3ToDisplayName: Record<string, string> = {
|
||||||
|
'SpontaneousSelfUse': 'LoadPriority',
|
||||||
|
'TimeChargeDischarge': 'BatteryPriority',
|
||||||
|
'PvPriorityCharging': 'GridPriority',
|
||||||
|
};
|
||||||
|
|
||||||
const [errors, setErrors] = useState({
|
const [errors, setErrors] = useState({
|
||||||
minimumSoC: false,
|
minimumSoC: false,
|
||||||
gridSetPoint: false
|
gridSetPoint: false
|
||||||
|
|
@ -81,39 +79,21 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
const { currentUser, setUser } = context;
|
const { currentUser, setUser } = context;
|
||||||
const { product, setProduct } = useContext(ProductIdContext);
|
const { product, setProduct } = useContext(ProductIdContext);
|
||||||
|
|
||||||
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>({
|
// Resolve S3 OperatingPriority to display index (Sinexcel uses different enum names)
|
||||||
minimumSoC: props.values.Config.MinSoc,
|
const resolveOperatingPriorityIndex = (s3Value: string) => {
|
||||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
const displayName = device === 4 ? (sinexcelS3ToDisplayName[s3Value] ?? s3Value) : s3Value;
|
||||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
return OperatingPriorityOptions.indexOf(displayName);
|
||||||
operatingPriority: OperatingPriorityOptions.indexOf(
|
};
|
||||||
props.values.Config.OperatingPriority
|
|
||||||
),
|
|
||||||
batteriesCount: props.values.Config.BatteriesCount,
|
|
||||||
clusterNumber: props.values.Config.ClusterNumber??1,
|
|
||||||
PvNumber: props.values.Config.PvNumber??0,
|
|
||||||
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0, // default 0 W
|
|
||||||
startTimeChargeandDischargeDayandTime:
|
|
||||||
props.values.Config?.StartTimeChargeandDischargeDayandTime
|
|
||||||
? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate()
|
|
||||||
: null,
|
|
||||||
stopTimeChargeandDischargeDayandTime:
|
|
||||||
props.values.Config?.StopTimeChargeandDischargeDayandTime
|
|
||||||
? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate()
|
|
||||||
: null,
|
|
||||||
|
|
||||||
// controlPermission: props.values.Config.ControlPermission??false,
|
|
||||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Storage key for pending config (optimistic update)
|
// Storage key for pending config (optimistic update)
|
||||||
const pendingConfigKey = `pendingConfig_${props.id}`;
|
const pendingConfigKey = `pendingConfig_${props.id}`;
|
||||||
|
|
||||||
// Helper to get current S3 values
|
// Helper to build form values from S3 data
|
||||||
const getS3Values = () => ({
|
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||||
minimumSoC: props.values.Config.MinSoc,
|
minimumSoC: props.values.Config.MinSoc,
|
||||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||||
operatingPriority: OperatingPriorityOptions.indexOf(
|
operatingPriority: resolveOperatingPriorityIndex(
|
||||||
props.values.Config.OperatingPriority
|
props.values.Config.OperatingPriority
|
||||||
),
|
),
|
||||||
batteriesCount: props.values.Config.BatteriesCount,
|
batteriesCount: props.values.Config.BatteriesCount,
|
||||||
|
|
@ -131,49 +111,83 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync form values when props.values changes
|
// Restore pending config from localStorage, converting date strings back to Date objects.
|
||||||
// Logic: Use localStorage only briefly after submit to prevent flicker, then trust S3
|
// Returns { values, s3ConfigSnapshot } or null if no pending config.
|
||||||
|
const restorePendingConfig = () => {
|
||||||
|
try {
|
||||||
|
const pendingStr = localStorage.getItem(pendingConfigKey);
|
||||||
|
if (!pendingStr) return null;
|
||||||
|
|
||||||
|
const pending = JSON.parse(pendingStr);
|
||||||
|
const v = pending.values;
|
||||||
|
const values: Partial<ConfigurationValues> = {
|
||||||
|
...v,
|
||||||
|
// JSON.stringify converts Date→string; restore them back to Date objects
|
||||||
|
startTimeChargeandDischargeDayandTime:
|
||||||
|
v.startTimeChargeandDischargeDayandTime
|
||||||
|
? dayjs(v.startTimeChargeandDischargeDayandTime).toDate()
|
||||||
|
: null,
|
||||||
|
stopTimeChargeandDischargeDayandTime:
|
||||||
|
v.stopTimeChargeandDischargeDayandTime
|
||||||
|
? dayjs(v.stopTimeChargeandDischargeDayandTime).toDate()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { values, s3ConfigSnapshot: pending.s3ConfigSnapshot || null };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Config:restore] Failed to parse localStorage', e);
|
||||||
|
localStorage.removeItem(pendingConfigKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fingerprint S3 Config for change detection (not value comparison)
|
||||||
|
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
|
||||||
|
|
||||||
|
// Initialize form from localStorage (if pending submit exists) or from S3
|
||||||
|
// This runs in the useState initializer so the component never renders stale values
|
||||||
|
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
|
||||||
|
const pending = restorePendingConfig();
|
||||||
|
const s3 = getS3Values();
|
||||||
|
if (pending) {
|
||||||
|
// Check if S3 has new data since submit (fingerprint changed from snapshot)
|
||||||
|
const currentFingerprint = getS3ConfigFingerprint();
|
||||||
|
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
|
||||||
|
|
||||||
|
if (s3Changed) {
|
||||||
|
// Device uploaded new data since our submit — trust S3 (device is authority)
|
||||||
|
localStorage.removeItem(pendingConfigKey);
|
||||||
|
return s3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 still has same data as when we submitted — show pending values
|
||||||
|
return pending.values;
|
||||||
|
}
|
||||||
|
return s3;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When S3 data updates (polled every 60s), reconcile with any pending localStorage.
|
||||||
|
// Strategy: device is the authority. Once S3 Config changes from the snapshot taken at
|
||||||
|
// submit time, the device has uploaded new data — trust S3 regardless of values.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s3Values = getS3Values();
|
const s3Values = getS3Values();
|
||||||
const pendingConfigStr = localStorage.getItem(pendingConfigKey);
|
const pending = restorePendingConfig();
|
||||||
|
|
||||||
if (pendingConfigStr) {
|
if (pending) {
|
||||||
try {
|
const currentFingerprint = getS3ConfigFingerprint();
|
||||||
const pendingConfig = JSON.parse(pendingConfigStr);
|
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
|
||||||
const submittedAt = pendingConfig.submittedAt || 0;
|
if (s3Changed) {
|
||||||
const timeSinceSubmit = Date.now() - submittedAt;
|
// S3 Config changed from snapshot → device uploaded new data → trust S3
|
||||||
|
|
||||||
// Within 300 seconds of submit: use localStorage (waiting for S3 sync)
|
|
||||||
// This covers two full S3 upload cycles (150 sec × 2) to ensure new file is available
|
|
||||||
if (timeSinceSubmit < 300000) {
|
|
||||||
// Check if S3 now matches - if so, sync is complete
|
|
||||||
const s3MatchesPending =
|
|
||||||
s3Values.controlPermission === pendingConfig.values.controlPermission &&
|
|
||||||
s3Values.minimumSoC === pendingConfig.values.minimumSoC &&
|
|
||||||
s3Values.operatingPriority === pendingConfig.values.operatingPriority;
|
|
||||||
|
|
||||||
if (s3MatchesPending) {
|
|
||||||
// S3 synced! Clear localStorage and use S3 from now on
|
|
||||||
console.log('S3 synced with submitted config');
|
|
||||||
localStorage.removeItem(pendingConfigKey);
|
localStorage.removeItem(pendingConfigKey);
|
||||||
setFormValues(s3Values);
|
setFormValues(s3Values);
|
||||||
} else {
|
} else {
|
||||||
// Still waiting for sync, keep showing submitted values
|
// S3 still has same data as at submit time — keep showing pending values
|
||||||
console.log('Waiting for S3 sync, showing submitted values');
|
setFormValues(pending.values);
|
||||||
setFormValues(pendingConfig.values);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout expired: clear localStorage, trust S3 completely
|
// No pending config — trust S3 (source of truth)
|
||||||
console.log('Timeout expired, trusting S3 data');
|
|
||||||
localStorage.removeItem(pendingConfigKey);
|
|
||||||
} catch (e) {
|
|
||||||
localStorage.removeItem(pendingConfigKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No localStorage or expired: always use S3 (source of truth)
|
|
||||||
setFormValues(s3Values);
|
setFormValues(s3Values);
|
||||||
}, [props.values]);
|
}, [props.values]);
|
||||||
|
|
||||||
|
|
@ -199,7 +213,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
const stopTimeInMinutes = stopHours * 60 + stopMinutes;
|
const stopTimeInMinutes = stopHours * 60 + stopMinutes;
|
||||||
|
|
||||||
if (startTimeInMinutes >= stopTimeInMinutes) {
|
if (startTimeInMinutes >= stopTimeInMinutes) {
|
||||||
setDateSelectionError('Stop time must be later than start time');
|
setDateSelectionError(intl.formatMessage({ id: 'stopTimeMustBeLater' }));
|
||||||
setErrorDateModalOpen(true);
|
setErrorDateModalOpen(true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -246,12 +260,15 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
setUpdated(true);
|
setUpdated(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Save submitted values to localStorage for optimistic UI update
|
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
|
||||||
// This ensures the form shows correct values even before S3 syncs (up to 150 sec delay)
|
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
|
||||||
localStorage.setItem(pendingConfigKey, JSON.stringify({
|
// When S3 Config changes from this snapshot, the device has uploaded new data.
|
||||||
|
const cachePayload = {
|
||||||
values: formValues,
|
values: formValues,
|
||||||
submittedAt: Date.now()
|
submittedAt: Date.now(),
|
||||||
}));
|
s3ConfigSnapshot: getS3ConfigFingerprint(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(pendingConfigKey, JSON.stringify(cachePayload));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -459,7 +476,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
{/* fullWidth*/}
|
{/* fullWidth*/}
|
||||||
{/*/>*/}
|
{/*/>*/}
|
||||||
<TextField
|
<TextField
|
||||||
label="Minimum SoC (%)"
|
label={intl.formatMessage({ id: 'minimumSocPercent' })}
|
||||||
name="minimumSoC"
|
name="minimumSoC"
|
||||||
value={formValues.minimumSoC}
|
value={formValues.minimumSoC}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -529,21 +546,21 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
{/* --- Sinexcel + TimeChargeDischarge --- */}
|
{/* --- Sinexcel + BatteryPriority (maps to TimeChargeDischarge on device) --- */}
|
||||||
{device === 4 &&
|
{device === 4 &&
|
||||||
OperatingPriorityOptions[formValues.operatingPriority] ===
|
OperatingPriorityOptions[formValues.operatingPriority] ===
|
||||||
'TimeChargeDischarge' && (
|
'BatteryPriority' && (
|
||||||
<>
|
<>
|
||||||
{/* Power input*/}
|
{/* Power input*/}
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Power (W)"
|
label={intl.formatMessage({ id: 'powerW' })}
|
||||||
name="timeChargeandDischargePower"
|
name="timeChargeandDischargePower"
|
||||||
value={formValues.timeChargeandDischargePower}
|
value={formValues.timeChargeandDischargePower}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
||||||
}
|
}
|
||||||
helperText="Enter a positive or negative power value"
|
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -553,7 +570,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
ampm={false}
|
ampm={false}
|
||||||
label="Start Date and Time (Start Time < Stop Time)"
|
label={intl.formatMessage({ id: 'startDateTime' })}
|
||||||
value={
|
value={
|
||||||
formValues.startTimeChargeandDischargeDayandTime
|
formValues.startTimeChargeandDischargeDayandTime
|
||||||
? dayjs(formValues.startTimeChargeandDischargeDayandTime)
|
? dayjs(formValues.startTimeChargeandDischargeDayandTime)
|
||||||
|
|
@ -585,7 +602,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
ampm={false}
|
ampm={false}
|
||||||
label="Stop Date and Time (Start Time < Stop Time)"
|
label={intl.formatMessage({ id: 'stopDateTime' })}
|
||||||
value={
|
value={
|
||||||
formValues.stopTimeChargeandDischargeDayandTime
|
formValues.stopTimeChargeandDischargeDayandTime
|
||||||
? dayjs(formValues.stopTimeChargeandDischargeDayandTime)
|
? dayjs(formValues.stopTimeChargeandDischargeDayandTime)
|
||||||
|
|
@ -651,7 +668,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Successfully applied configuration file
|
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -671,7 +688,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
An error has occurred
|
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,11 @@ import {
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
import DailySection from './DailySection';
|
||||||
|
|
||||||
interface WeeklyReportProps {
|
interface WeeklyReportProps {
|
||||||
installationId: number;
|
installationId: number;
|
||||||
|
|
@ -273,6 +276,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
{ label: intl.formatMessage({ id: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
|
||||||
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
|
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
|
||||||
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
|
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
|
||||||
{ label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' }
|
{ label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' }
|
||||||
|
|
@ -281,15 +285,36 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
const safeTab = Math.min(activeTab, tabs.length - 1);
|
const safeTab = Math.min(activeTab, tabs.length - 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }}>
|
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }} className="report-container">
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
body * { visibility: hidden; }
|
||||||
|
.report-container, .report-container * { visibility: visible; }
|
||||||
|
.report-container { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={safeTab}
|
value={safeTab}
|
||||||
onChange={(_, v) => setActiveTab(v)}
|
onChange={(_, v) => setActiveTab(v)}
|
||||||
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
sx={{ flex: 1, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
||||||
>
|
>
|
||||||
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
sx={{ ml: 2, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}>
|
||||||
|
<DailySection installationId={installationId} />
|
||||||
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
|
||||||
<WeeklySection
|
<WeeklySection
|
||||||
installationId={installationId}
|
installationId={installationId}
|
||||||
|
|
@ -334,19 +359,19 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
fetchReport();
|
fetchReport();
|
||||||
}, [installationId, intl.locale]);
|
}, [installationId, intl.locale]);
|
||||||
|
|
||||||
const fetchReport = async () => {
|
const fetchReport = async (forceRegenerate = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await axiosConfig.get('/GetWeeklyReport', {
|
const res = await axiosConfig.get('/GetWeeklyReport', {
|
||||||
params: { installationId, language: intl.locale }
|
params: { installationId, language: intl.locale, forceRegenerate }
|
||||||
});
|
});
|
||||||
setReport(res.data);
|
setReport(res.data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg =
|
const msg =
|
||||||
err.response?.data ||
|
err.response?.data ||
|
||||||
err.message ||
|
err.message ||
|
||||||
'Failed to load report. Make sure the Excel file is placed in tmp_report/';
|
intl.formatMessage({ id: 'failedToLoadReport' });
|
||||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -392,7 +417,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
const prev = report.previousWeek;
|
const prev = report.previousWeek;
|
||||||
|
|
||||||
const currentWeekDayCount = Math.min(7, report.dailyData.length);
|
const currentWeekDayCount = Math.min(7, report.dailyData.length);
|
||||||
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount);
|
const previousWeekDayCount = 7;
|
||||||
|
|
||||||
const formatChange = (pct: number) =>
|
const formatChange = (pct: number) =>
|
||||||
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
|
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
|
||||||
|
|
@ -434,6 +459,8 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
borderRadius: 2
|
borderRadius: 2
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box>
|
||||||
<Typography variant="h5" fontWeight="bold">
|
<Typography variant="h5" fontWeight="bold">
|
||||||
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -443,6 +470,17 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||||
{report.periodStart} — {report.periodEnd}
|
{report.periodStart} — {report.periodEnd}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => fetchReport(true)}
|
||||||
|
sx={{ color: '#fff', borderColor: 'rgba(255,255,255,0.5)', '&:hover': { borderColor: '#fff' } }}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Weekly Insights */}
|
{/* Weekly Insights */}
|
||||||
|
|
@ -665,7 +703,7 @@ function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPe
|
||||||
<Grid item xs={6} sm={3}>
|
<Grid item xs={6} sm={3}>
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
<Typography variant="body1" fontWeight="bold" color="#8e44ad">{rec.selfSufficiencyPercent.toFixed(0)}%</Typography>
|
<Typography variant="body1" fontWeight="bold" color="#8e44ad">{rec.selfSufficiencyPercent.toFixed(0)}%</Typography>
|
||||||
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Self-Sufficiency" /></Typography>
|
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Energy Independence" /></Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} sm={3}>
|
<Grid item xs={6} sm={3}>
|
||||||
|
|
@ -760,6 +798,7 @@ function MonthlySection({
|
||||||
countFn={(r: MonthlyReport) => r.weekCount}
|
countFn={(r: MonthlyReport) => r.weekCount}
|
||||||
sendEndpoint="/SendMonthlyReportEmail"
|
sendEndpoint="/SendMonthlyReportEmail"
|
||||||
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
||||||
|
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
|
||||||
/>
|
/>
|
||||||
) : pendingMonths.length === 0 ? (
|
) : pendingMonths.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
|
|
@ -833,6 +872,7 @@ function YearlySection({
|
||||||
countFn={(r: YearlyReport) => r.monthCount}
|
countFn={(r: YearlyReport) => r.monthCount}
|
||||||
sendEndpoint="/SendYearlyReportEmail"
|
sendEndpoint="/SendYearlyReportEmail"
|
||||||
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
||||||
|
onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
|
||||||
/>
|
/>
|
||||||
) : pendingYears.length === 0 ? (
|
) : pendingYears.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
|
|
@ -852,7 +892,8 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
countLabelId,
|
countLabelId,
|
||||||
countFn,
|
countFn,
|
||||||
sendEndpoint,
|
sendEndpoint,
|
||||||
sendParamsFn
|
sendParamsFn,
|
||||||
|
onRegenerate
|
||||||
}: {
|
}: {
|
||||||
reports: T[];
|
reports: T[];
|
||||||
type: 'monthly' | 'yearly';
|
type: 'monthly' | 'yearly';
|
||||||
|
|
@ -861,9 +902,11 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
countFn: (r: T) => number;
|
countFn: (r: T) => number;
|
||||||
sendEndpoint: string;
|
sendEndpoint: string;
|
||||||
sendParamsFn: (r: T) => object;
|
sendParamsFn: (r: T) => object;
|
||||||
|
onRegenerate?: (r: T) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
|
||||||
if (reports.length === 0) {
|
if (reports.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -909,7 +952,22 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ ml: 'auto' }}>
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{onRegenerate && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
disabled={regenerating}
|
||||||
|
startIcon={regenerating ? <CircularProgress size={14} /> : <RefreshIcon />}
|
||||||
|
onClick={async () => {
|
||||||
|
setRegenerating(true);
|
||||||
|
try { await onRegenerate(r); } finally { setRegenerating(false); }
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<EmailBar onSend={handleSendEmail} />
|
<EmailBar onSend={handleSendEmail} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -1016,7 +1074,7 @@ function InsightBox({ text, bullets }: { text: string; bullets: string[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
|
export function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
|
||||||
intl: any;
|
intl: any;
|
||||||
energySaved: number;
|
energySaved: number;
|
||||||
savingsCHF: number;
|
savingsCHF: number;
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
icon={tab.icon}
|
icon={tab.icon}
|
||||||
component={Link}
|
component={Link}
|
||||||
label={tab.label}
|
label={tab.label}
|
||||||
|
id={`tour-tab-${tab.value}`}
|
||||||
to={
|
to={
|
||||||
tab.value === 'list' || tab.value === 'tree'
|
tab.value === 'list' || tab.value === 'tree'
|
||||||
? routes[tab.value]
|
? routes[tab.value]
|
||||||
|
|
@ -482,6 +483,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
component={Link}
|
component={Link}
|
||||||
label={tab.label}
|
label={tab.label}
|
||||||
to={routes[tab.value]}
|
to={routes[tab.value]}
|
||||||
|
id={`tour-tab-${tab.value}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
JSONRecordData
|
JSONRecordData
|
||||||
} from '../Log/graph.util';
|
} from '../Log/graph.util';
|
||||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
interface TopologyProps {
|
interface TopologyProps {
|
||||||
values: JSONRecordData;
|
values: JSONRecordData;
|
||||||
|
|
@ -64,10 +65,10 @@ function Topology(props: TopologyProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Unable to communicate with the installation
|
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
JSONRecordData
|
JSONRecordData
|
||||||
} from '../Log/graph.util';
|
} from '../Log/graph.util';
|
||||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
interface TopologySodistoreHomeProps {
|
interface TopologySodistoreHomeProps {
|
||||||
values: JSONRecordData;
|
values: JSONRecordData;
|
||||||
|
|
@ -38,29 +39,31 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
|
|
||||||
const isMobile = window.innerWidth <= 1490;
|
const isMobile = window.innerWidth <= 1490;
|
||||||
|
|
||||||
const totalBatteryPower: number = Number(
|
const inv = props.values?.InverterRecord;
|
||||||
props.values && props.values.InverterRecord
|
const hasDevices = !!inv?.Devices;
|
||||||
? Array.from({ length: props.batteryClusterNumber }).reduce(
|
|
||||||
(sum: number, _, index) => {
|
|
||||||
const i = index + 1;
|
|
||||||
|
|
||||||
const rawPower =
|
const totalBatteryPower: number = hasDevices
|
||||||
props.values.InverterRecord[`Battery${i}Power`] as unknown;
|
? (inv?.TotalBatteryPower ?? 0)
|
||||||
|
: Number(
|
||||||
const power = Number(rawPower) || 0;
|
Array.from({ length: props.batteryClusterNumber }).reduce(
|
||||||
|
(sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0),
|
||||||
return sum + power;
|
|
||||||
},
|
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
: 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pvPower =
|
const pvPower: number = hasDevices
|
||||||
props.values?.InverterRecord?.PvPower ??
|
? (inv?.TotalPhotovoltaicPower ?? 0)
|
||||||
|
: (inv?.PvPower ??
|
||||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
||||||
.map((key) => props.values?.InverterRecord?.[key] ?? 0)
|
.map((key) => inv?.[key] ?? 0)
|
||||||
.reduce((sum, val) => sum + val, 0);
|
.reduce((sum, val) => sum + val, 0));
|
||||||
|
|
||||||
|
const totalLoadPower: number = hasDevices
|
||||||
|
? (inv?.TotalLoadPower ?? 0)
|
||||||
|
: (inv?.ConsumptionPower ?? 0);
|
||||||
|
|
||||||
|
const totalGridPower: number =
|
||||||
|
inv?.TotalGridPower ?? inv?.GridPower ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
||||||
|
|
@ -82,10 +85,10 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
style={{ color: 'black', fontWeight: 'bold' }}
|
style={{ color: 'black', fontWeight: 'bold' }}
|
||||||
mt={2}
|
mt={2}
|
||||||
>
|
>
|
||||||
Unable to communicate with the installation
|
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" style={{ color: 'black' }}>
|
<Typography variant="body2" style={{ color: 'black' }}>
|
||||||
Please wait or refresh the page
|
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
@ -141,7 +144,7 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
data: props.values?.InverterRecord
|
data: props.values?.InverterRecord
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
value: props.values.InverterRecord.GridPower,
|
value: totalGridPower,
|
||||||
unit: 'W'
|
unit: 'W'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -153,14 +156,14 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
orientation: 'horizontal',
|
orientation: 'horizontal',
|
||||||
data: props.values?.InverterRecord
|
data: props.values?.InverterRecord
|
||||||
? {
|
? {
|
||||||
value: props.values.InverterRecord.GridPower,
|
value: totalGridPower,
|
||||||
unit: 'W'
|
unit: 'W'
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
amount: props.values?.InverterRecord
|
amount: props.values?.InverterRecord
|
||||||
? getAmount(
|
? getAmount(
|
||||||
highestConnectionValue,
|
highestConnectionValue,
|
||||||
props.values.InverterRecord.GridPower
|
totalGridPower
|
||||||
)
|
)
|
||||||
: 0,
|
: 0,
|
||||||
showValues: showValues
|
showValues: showValues
|
||||||
|
|
@ -224,7 +227,7 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
data: props.values?.InverterRecord
|
data: props.values?.InverterRecord
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
value: props.values.InverterRecord.ConsumptionPower,
|
value: totalLoadPower,
|
||||||
unit: 'W'
|
unit: 'W'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -236,14 +239,14 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
data: props.values?.InverterRecord
|
data: props.values?.InverterRecord
|
||||||
? {
|
? {
|
||||||
value: props.values.InverterRecord.ConsumptionPower,
|
value: totalLoadPower,
|
||||||
unit: 'W'
|
unit: 'W'
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
amount: props.values?.InverterRecord
|
amount: props.values?.InverterRecord
|
||||||
? getAmount(
|
? getAmount(
|
||||||
highestConnectionValue,
|
highestConnectionValue,
|
||||||
props.values.InverterRecord.ConsumptionPower
|
totalLoadPower
|
||||||
)
|
)
|
||||||
: 0,
|
: 0,
|
||||||
showValues: showValues
|
showValues: showValues
|
||||||
|
|
@ -253,23 +256,32 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
/>
|
/>
|
||||||
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
|
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
|
||||||
{Array.from({ length: props.batteryClusterNumber }).map((_, index) => {
|
{Array.from({ length: props.batteryClusterNumber }).map((_, index) => {
|
||||||
const i = index + 1; // battery cluster index starting from 1
|
let soc: number;
|
||||||
|
let power: number;
|
||||||
|
|
||||||
|
if (hasDevices) {
|
||||||
|
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
|
||||||
|
const deviceId = String(Math.floor(index / 2) + 1);
|
||||||
|
const batteryIndex = (index % 2) + 1;
|
||||||
|
const device = inv?.Devices?.[deviceId];
|
||||||
|
soc = device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0;
|
||||||
|
power = device?.[`Battery${batteryIndex}Power`] ?? 0;
|
||||||
|
} else {
|
||||||
|
// Growatt: flat Battery1, Battery2, ...
|
||||||
|
const i = index + 1;
|
||||||
|
soc = Number(inv?.[`Battery${i}Soc`]) || 0;
|
||||||
|
power = Number(inv?.[`Battery${i}Power`]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopologyColumn
|
<TopologyColumn
|
||||||
key={i}
|
key={index + 1}
|
||||||
centerBox={{
|
centerBox={{
|
||||||
title: `Battery C${i}`,
|
title: `Battery C${index + 1}`,
|
||||||
data: props.values.InverterRecord
|
data: inv
|
||||||
? [
|
? [
|
||||||
{
|
{ value: soc, unit: '%' },
|
||||||
value: props.values.InverterRecord[`Battery${i}Soc`],
|
{ value: power, unit: 'W' }
|
||||||
unit: '%'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: props.values.InverterRecord[`Battery${i}Power`],
|
|
||||||
unit: 'W'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
connected: true
|
connected: true
|
||||||
|
|
|
||||||
|
|
@ -177,14 +177,14 @@ function TreeInformation(props: TreeInformationProps) {
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{ fontWeight: 'bold' }}
|
sx={{ fontWeight: 'bold' }}
|
||||||
>
|
>
|
||||||
Do you want to delete this folder?
|
<FormattedMessage id="confirmDeleteFolder" defaultMessage="Do you want to delete this folder?" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{ fontSize: '0.875rem' }}
|
sx={{ fontSize: '0.875rem' }}
|
||||||
>
|
>
|
||||||
All installations of this folder will be deleted.
|
<FormattedMessage id="deleteFolderWarning" defaultMessage="All installations of this folder will be deleted." />
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -204,7 +204,7 @@ function TreeInformation(props: TreeInformationProps) {
|
||||||
}}
|
}}
|
||||||
onClick={deleteFolderModalHandle}
|
onClick={deleteFolderModalHandle}
|
||||||
>
|
>
|
||||||
Delete
|
<FormattedMessage id="delete" defaultMessage="Delete" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -217,7 +217,7 @@ function TreeInformation(props: TreeInformationProps) {
|
||||||
}}
|
}}
|
||||||
onClick={deleteFolderModalHandleCancel}
|
onClick={deleteFolderModalHandleCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
|
|
||||||
|
|
@ -57,8 +58,8 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell padding="checkbox"></TableCell>
|
<TableCell padding="checkbox"></TableCell>
|
||||||
<TableCell>Username</TableCell>
|
<TableCell><FormattedMessage id="username" defaultMessage="Username" /></TableCell>
|
||||||
<TableCell>Email</TableCell>
|
<TableCell><FormattedMessage id="email" defaultMessage="Email" /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
||||||
import { TokenContext } from 'src/contexts/tokenContext';
|
import { TokenContext } from 'src/contexts/tokenContext';
|
||||||
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
|
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import UserAccess from '../ManageAccess/UserAccess';
|
import UserAccess from '../ManageAccess/UserAccess';
|
||||||
|
|
||||||
interface singleUserProps {
|
interface singleUserProps {
|
||||||
|
|
@ -35,6 +35,7 @@ interface singleUserProps {
|
||||||
|
|
||||||
function User(props: singleUserProps) {
|
function User(props: singleUserProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [updated, setUpdated] = useState(false);
|
const [updated, setUpdated] = useState(false);
|
||||||
|
|
@ -43,8 +44,8 @@ function User(props: singleUserProps) {
|
||||||
const tokencontext = useContext(TokenContext);
|
const tokencontext = useContext(TokenContext);
|
||||||
const { removeToken } = tokencontext;
|
const { removeToken } = tokencontext;
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'user', label: 'User' },
|
{ value: 'user', label: intl.formatMessage({ id: 'user' }) },
|
||||||
{ value: 'manage', label: 'Access Management' }
|
{ value: 'manage', label: intl.formatMessage({ id: 'accessManagement' }) }
|
||||||
];
|
];
|
||||||
const [openModalDeleteUser, setOpenModalDeleteUser] = useState(false);
|
const [openModalDeleteUser, setOpenModalDeleteUser] = useState(false);
|
||||||
|
|
||||||
|
|
@ -190,7 +191,7 @@ function User(props: singleUserProps) {
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{ fontWeight: 'bold' }}
|
sx={{ fontWeight: 'bold' }}
|
||||||
>
|
>
|
||||||
Do you want to delete this user?
|
<FormattedMessage id="confirmDeleteUser" defaultMessage="Do you want to delete this user?" />
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -210,7 +211,7 @@ function User(props: singleUserProps) {
|
||||||
}}
|
}}
|
||||||
onClick={deleteUserModalHandle}
|
onClick={deleteUserModalHandle}
|
||||||
>
|
>
|
||||||
Delete
|
<FormattedMessage id="delete" defaultMessage="Delete" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -223,7 +224,7 @@ function User(props: singleUserProps) {
|
||||||
}}
|
}}
|
||||||
onClick={deleteUserModalHandleCancel}
|
onClick={deleteUserModalHandleCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -274,7 +275,7 @@ function User(props: singleUserProps) {
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label={intl.formatMessage({ id: 'name' })}
|
||||||
name="name"
|
name="name"
|
||||||
value={formValues.name}
|
value={formValues.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -283,7 +284,7 @@ function User(props: singleUserProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="Email"
|
label={intl.formatMessage({ id: 'email' })}
|
||||||
name="email"
|
name="email"
|
||||||
value={formValues.email}
|
value={formValues.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -293,7 +294,7 @@ function User(props: singleUserProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="Information"
|
label={intl.formatMessage({ id: 'information' })}
|
||||||
name="information"
|
name="information"
|
||||||
value={formValues.information}
|
value={formValues.information}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@ import { AccessContext } from '../../../contexts/AccessContextProvider';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import UserForm from './userForm';
|
import UserForm from './userForm';
|
||||||
import { UserContext } from '../../../contexts/userContext';
|
import { UserContext } from '../../../contexts/userContext';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { Close as CloseIcon } from '@mui/icons-material';
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
|
|
||||||
function UsersSearch() {
|
function UsersSearch() {
|
||||||
|
const intl = useIntl();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const { availableUsers, fetchAvailableUsers } = useContext(AccessContext);
|
const { availableUsers, fetchAvailableUsers } = useContext(AccessContext);
|
||||||
const [filteredData, setFilteredData] = useState(availableUsers);
|
const [filteredData, setFilteredData] = useState(availableUsers);
|
||||||
|
|
@ -147,7 +148,7 @@ function UsersSearch() {
|
||||||
<Grid item xs={12} md={isMobile ? 5 : 3}>
|
<Grid item xs={12} md={isMobile ? 5 : 3}>
|
||||||
<FormControl variant="outlined" fullWidth>
|
<FormControl variant="outlined" fullWidth>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search"
|
placeholder={intl.formatMessage({ id: 'search' })}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import { TokenContext } from 'src/contexts/tokenContext';
|
import { TokenContext } from 'src/contexts/tokenContext';
|
||||||
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
interface userFormProps {
|
interface userFormProps {
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
|
|
@ -27,10 +27,11 @@ interface userFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function userForm(props: userFormProps) {
|
function userForm(props: userFormProps) {
|
||||||
|
const intl = useIntl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [errormessage, setErrorMessage] = useState('An error has occured');
|
const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' }));
|
||||||
const [openInstallation, setOpenInstallation] = useState(false);
|
const [openInstallation, setOpenInstallation] = useState(false);
|
||||||
const [openFolder, setOpenFolder] = useState(false);
|
const [openFolder, setOpenFolder] = useState(false);
|
||||||
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
|
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
|
||||||
|
|
@ -174,7 +175,7 @@ function userForm(props: userFormProps) {
|
||||||
.delete(`/DeleteUser?userId=${res.data.id}`)
|
.delete(`/DeleteUser?userId=${res.data.id}`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setErrorMessage('An error has occured');
|
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
|
||||||
setError(true);
|
setError(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
props.cancel();
|
props.cancel();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import RefreshTwoToneIcon from '@mui/icons-material/RefreshTwoTone';
|
import RefreshTwoToneIcon from '@mui/icons-material/RefreshTwoTone';
|
||||||
import LoadingButton from '@mui/lab/LoadingButton';
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const GridWrapper = styled(Grid)(
|
const GridWrapper = styled(Grid)(
|
||||||
({ theme }) => `
|
({ theme }) => `
|
||||||
|
|
@ -77,7 +78,7 @@ function Status500() {
|
||||||
src="/static/images/status/500.svg"
|
src="/static/images/status/500.svg"
|
||||||
/>
|
/>
|
||||||
<Typography variant="h2" sx={{ my: 2 }}>
|
<Typography variant="h2" sx={{ my: 2 }}>
|
||||||
There was an error, please try again later
|
<FormattedMessage id="serverError" defaultMessage="There was an error, please try again later" />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h4"
|
variant="h4"
|
||||||
|
|
@ -85,8 +86,7 @@ function Status500() {
|
||||||
fontWeight="normal"
|
fontWeight="normal"
|
||||||
sx={{ mb: 4 }}
|
sx={{ mb: 4 }}
|
||||||
>
|
>
|
||||||
The server encountered an internal error and was not able to
|
<FormattedMessage id="serverInternalError" defaultMessage="The server encountered an internal error and was not able to complete your request" />
|
||||||
complete your request
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -95,10 +95,10 @@ function Status500() {
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<RefreshTwoToneIcon />}
|
startIcon={<RefreshTwoToneIcon />}
|
||||||
>
|
>
|
||||||
Refresh view
|
<FormattedMessage id="refreshView" defaultMessage="Refresh view" />
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<Button href="/overview" variant="contained" sx={{ ml: 1 }}>
|
<Button href="/overview" variant="contained" sx={{ ml: 1 }}>
|
||||||
Go back
|
<FormattedMessage id="goBack" defaultMessage="Go back" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ const InstallationsContextProvider = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
applyBatchUpdates();
|
applyBatchUpdates();
|
||||||
}, 60000);
|
}, 2000);
|
||||||
|
|
||||||
return () => clearInterval(timer); // Cleanup timer on component unmount
|
return () => clearInterval(timer); // Cleanup timer on component unmount
|
||||||
}, [applyBatchUpdates]);
|
}, [applyBatchUpdates]);
|
||||||
|
|
@ -132,7 +132,7 @@ const InstallationsContextProvider = ({
|
||||||
socket.current.close();
|
socket.current.close();
|
||||||
socket.current = null;
|
socket.current = null;
|
||||||
}
|
}
|
||||||
const tokenString = localStorage.getItem('token');
|
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||||
const token = tokenString !== null ? tokenString : '';
|
const token = tokenString !== null ? tokenString : '';
|
||||||
const urlWithToken = `wss://monitor.inesco.energy/api/CreateWebSocket?authToken=${token}`;
|
const urlWithToken = `wss://monitor.inesco.energy/api/CreateWebSocket?authToken=${token}`;
|
||||||
|
|
||||||
|
|
@ -156,8 +156,15 @@ const InstallationsContextProvider = ({
|
||||||
|
|
||||||
new_socket.addEventListener('message', (event) => {
|
new_socket.addEventListener('message', (event) => {
|
||||||
const message = JSON.parse(event.data); // Parse the JSON data
|
const message = JSON.parse(event.data); // Parse the JSON data
|
||||||
if (message.id !== -1) {
|
|
||||||
//For each received message (except the first one which is a batch, call the updateInstallationStatus function in order to import the message to the pendingUpdates list
|
// Initial batch from backend is an array, subsequent updates are single objects
|
||||||
|
if (Array.isArray(message)) {
|
||||||
|
message.forEach((msg) => {
|
||||||
|
if (msg.id !== -1) {
|
||||||
|
updateInstallationStatus(msg.id, msg.status, msg.testingMode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (message.id !== -1) {
|
||||||
updateInstallationStatus(
|
updateInstallationStatus(
|
||||||
message.id,
|
message.id,
|
||||||
message.status,
|
message.status,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface TourContextType {
|
||||||
|
runTour: boolean;
|
||||||
|
startTour: () => void;
|
||||||
|
stopTour: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TourContext = createContext<TourContextType>({
|
||||||
|
runTour: false,
|
||||||
|
startTour: () => {},
|
||||||
|
stopTour: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useTour = () => useContext(TourContext);
|
||||||
|
|
||||||
|
interface TourProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TourProvider({ children }: TourProviderProps) {
|
||||||
|
const [runTour, setRunTour] = useState(false);
|
||||||
|
|
||||||
|
const startTour = useCallback(() => setRunTour(true), []);
|
||||||
|
const stopTour = useCallback(() => setRunTour(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourContext.Provider value={{ runTour, startTour, stopTour }}>
|
||||||
|
{children}
|
||||||
|
</TourContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import {createContext, ReactNode, useState} from 'react';
|
||||||
// Define the shape of the context
|
// Define the shape of the context
|
||||||
interface TokenContextType {
|
interface TokenContextType {
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
setNewToken: (new_token: string) => void;
|
setNewToken: (new_token: string, rememberMe?: boolean) => void;
|
||||||
removeToken: () => void;
|
removeToken: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12,20 +12,29 @@ export const TokenContext = createContext<TokenContextType | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getStoredToken = (): string | null => {
|
||||||
|
// Check localStorage first (rememberMe was checked), then sessionStorage
|
||||||
|
return localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||||
|
};
|
||||||
|
|
||||||
// Create a UserContextProvider component
|
// Create a UserContextProvider component
|
||||||
export const TokenContextProvider = ({ children }: { children: ReactNode }) => {
|
export const TokenContextProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const [token, setToken] = useState(getStoredToken);
|
||||||
const tokenId = parseInt(searchParams.get('authToken'));
|
|
||||||
//Initialize context state with a "null" user
|
|
||||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
|
||||||
|
|
||||||
const saveToken = (new_token: string) => {
|
const saveToken = (new_token: string, rememberMe = false) => {
|
||||||
setToken(new_token);
|
setToken(new_token);
|
||||||
|
if (rememberMe) {
|
||||||
localStorage.setItem('token', new_token);
|
localStorage.setItem('token', new_token);
|
||||||
|
sessionStorage.removeItem('token');
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem('token', new_token);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteToken = () => {
|
const deleteToken = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
sessionStorage.removeItem('token');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {InnovEnergyUser} from '../interfaces/UserTypes';
|
||||||
// Define the shape of the context
|
// Define the shape of the context
|
||||||
interface UserContextType {
|
interface UserContextType {
|
||||||
currentUser?: InnovEnergyUser;
|
currentUser?: InnovEnergyUser;
|
||||||
setUser: (user: InnovEnergyUser) => void;
|
setUser: (user: InnovEnergyUser, rememberMe?: boolean) => void;
|
||||||
removeUser: () => void;
|
removeUser: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,20 +13,26 @@ export const UserContext = createContext<UserContextType | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getStoredUser = (): InnovEnergyUser | null => {
|
||||||
|
const data = localStorage.getItem('currentUser') || sessionStorage.getItem('currentUser');
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
};
|
||||||
|
|
||||||
// Create a UserContextProvider component
|
// Create a UserContextProvider component
|
||||||
export const UserContextProvider = ({ children }: { children: ReactNode }) => {
|
export const UserContextProvider = ({ children }: { children: ReactNode }) => {
|
||||||
//Initialize context state with a "null" user
|
const [currentUser, setUser] = useState<InnovEnergyUser>(getStoredUser);
|
||||||
const [currentUser, setUser] = useState<InnovEnergyUser>(
|
|
||||||
JSON.parse(localStorage.getItem('currentUser'))
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveUser = (new_user: InnovEnergyUser) => {
|
const saveUser = (new_user: InnovEnergyUser, rememberMe?: boolean) => {
|
||||||
setUser(new_user);
|
setUser(new_user);
|
||||||
localStorage.setItem('currentUser', JSON.stringify(new_user));
|
const storage = rememberMe !== undefined
|
||||||
|
? (rememberMe ? localStorage : sessionStorage)
|
||||||
|
: (localStorage.getItem('currentUser') ? localStorage : sessionStorage);
|
||||||
|
storage.setItem('currentUser', JSON.stringify(new_user));
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = () => {
|
const deleteUser = () => {
|
||||||
localStorage.removeItem('currentUser');
|
localStorage.removeItem('currentUser');
|
||||||
|
sessionStorage.removeItem('currentUser');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,22 @@ export class S3Access {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public list(marker?: string, maxKeys: number = 50): Promise<Response> {
|
||||||
|
const method = "GET";
|
||||||
|
const auth = this.createAuthorizationHeader(method, "", "");
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (marker) params.set("marker", marker);
|
||||||
|
params.set("max-keys", maxKeys.toString());
|
||||||
|
const url = this.url + "/" + this.bucket + "/?" + params.toString();
|
||||||
|
const headers = { Host: this.host, Authorization: auth };
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fetch(url, { method: method, mode: "cors", headers: headers });
|
||||||
|
} catch {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private createAuthorizationHeader(
|
private createAuthorizationHeader(
|
||||||
method: string,
|
method: string,
|
||||||
s3Path: string,
|
s3Path: string,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface chartAggregatedDataInterface {
|
||||||
gridImportPower: { name: string; data: number[] };
|
gridImportPower: { name: string; data: number[] };
|
||||||
gridExportPower: { name: string; data: number[] };
|
gridExportPower: { name: string; data: number[] };
|
||||||
heatingPower: { name: string; data: number[] };
|
heatingPower: { name: string; data: number[] };
|
||||||
|
acLoad: { name: string; data: number[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface chartDataInterface {
|
export interface chartDataInterface {
|
||||||
|
|
@ -516,36 +517,27 @@ export const transformInputToDailyDataJson = async (
|
||||||
let value: number | undefined = undefined;
|
let value: number | undefined = undefined;
|
||||||
|
|
||||||
if (product === 2) {
|
if (product === 2) {
|
||||||
// SodioHome: custom extraction with fallbacks for Growatt/Sinexcel
|
// SodioHome: use top-level aggregated values (Sinexcel multi-inverter)
|
||||||
const inv = result?.InverterRecord;
|
const inv = result?.InverterRecord;
|
||||||
if (inv) {
|
if (inv) {
|
||||||
switch (category_index) {
|
switch (category_index) {
|
||||||
case 0: // soc
|
case 0: // soc
|
||||||
value = inv.Battery1Soc;
|
value = inv.AvgBatterySoc ?? inv.Battery1Soc;
|
||||||
break;
|
break;
|
||||||
case 1: // temperature
|
case 1: // temperature
|
||||||
// Growatt: Battery1AmbientTemperature, Sinexcel: Battery1Temperature
|
value = inv.AvgBatteryTemp ?? inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
|
||||||
value = inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
|
|
||||||
break;
|
break;
|
||||||
case 2: // battery power
|
case 2: // battery power
|
||||||
value = inv.Battery1Power;
|
value = inv.TotalBatteryPower ?? inv.Battery1Power;
|
||||||
break;
|
break;
|
||||||
case 3: // grid power
|
case 3: // grid power
|
||||||
// Growatt: GridPower (always valid), Sinexcel: GridPower may be 0 when
|
|
||||||
// electric meter is offline, TotalGridPower is the reliable fallback
|
|
||||||
value = inv.TotalGridPower ?? inv.GridPower;
|
value = inv.TotalGridPower ?? inv.GridPower;
|
||||||
break;
|
break;
|
||||||
case 4: // pv production
|
case 4: // pv production
|
||||||
// Growatt: PvPower (aggregated), Sinexcel: PvTotalPower or sum PvPower1-4
|
value = inv.TotalPhotovoltaicPower ?? inv.PvPower ?? inv.PvTotalPower;
|
||||||
value =
|
|
||||||
inv.PvPower ??
|
|
||||||
inv.PvTotalPower ??
|
|
||||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
|
||||||
.map((key) => inv[key] ?? 0)
|
|
||||||
.reduce((sum, val) => sum + val, 0);
|
|
||||||
break;
|
break;
|
||||||
case 6: // consumption
|
case 6: // consumption
|
||||||
value = inv.ConsumptionPower;
|
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -613,6 +605,10 @@ export const transformInputToDailyDataJson = async (
|
||||||
'(' + prefixes[chartOverview['pvProduction'].magnitude] + 'W' + ')';
|
'(' + prefixes[chartOverview['pvProduction'].magnitude] + 'W' + ')';
|
||||||
chartOverview.dcBusVoltage.unit =
|
chartOverview.dcBusVoltage.unit =
|
||||||
'(' + prefixes[chartOverview['dcBusVoltage'].magnitude] + 'V' + ')';
|
'(' + prefixes[chartOverview['dcBusVoltage'].magnitude] + 'V' + ')';
|
||||||
|
chartOverview.ACLoad.unit =
|
||||||
|
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
|
||||||
|
chartOverview.DCLoad.unit =
|
||||||
|
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
|
||||||
|
|
||||||
chartOverview.overview = {
|
chartOverview.overview = {
|
||||||
magnitude: Math.max(
|
magnitude: Math.max(
|
||||||
|
|
@ -664,7 +660,8 @@ const fetchJsonDataForOneTime = async (
|
||||||
export const transformInputToAggregatedDataJson = async (
|
export const transformInputToAggregatedDataJson = async (
|
||||||
s3Credentials: I_S3Credentials,
|
s3Credentials: I_S3Credentials,
|
||||||
start_date: dayjs.Dayjs,
|
start_date: dayjs.Dayjs,
|
||||||
end_date: dayjs.Dayjs
|
end_date: dayjs.Dayjs,
|
||||||
|
product?: number
|
||||||
): Promise<{
|
): Promise<{
|
||||||
chartAggregatedData: chartAggregatedDataInterface;
|
chartAggregatedData: chartAggregatedDataInterface;
|
||||||
chartOverview: overviewInterface;
|
chartOverview: overviewInterface;
|
||||||
|
|
@ -685,7 +682,8 @@ export const transformInputToAggregatedDataJson = async (
|
||||||
'ChargingBatteryPower',
|
'ChargingBatteryPower',
|
||||||
'GridImportPower',
|
'GridImportPower',
|
||||||
'GridExportPower',
|
'GridExportPower',
|
||||||
'HeatingPower'
|
'HeatingPower',
|
||||||
|
'LoadPowerConsumption'
|
||||||
];
|
];
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
|
|
@ -707,7 +705,8 @@ export const transformInputToAggregatedDataJson = async (
|
||||||
heatingPower: { name: 'Heating Energy', data: [] },
|
heatingPower: { name: 'Heating Energy', data: [] },
|
||||||
dcDischargingPower: { name: 'Discharging Battery Energy', data: [] },
|
dcDischargingPower: { name: 'Discharging Battery Energy', data: [] },
|
||||||
gridImportPower: { name: 'Grid Import Energy', data: [] },
|
gridImportPower: { name: 'Grid Import Energy', data: [] },
|
||||||
gridExportPower: { name: 'Grid Export Energy', data: [] }
|
gridExportPower: { name: 'Grid Export Energy', data: [] },
|
||||||
|
acLoad: { name: 'AC Load', data: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartOverview: overviewInterface = {
|
const chartOverview: overviewInterface = {
|
||||||
|
|
@ -736,8 +735,11 @@ export const transformInputToAggregatedDataJson = async (
|
||||||
const timestampPromises = [];
|
const timestampPromises = [];
|
||||||
|
|
||||||
while (currentDay.isBefore(end_date)) {
|
while (currentDay.isBefore(end_date)) {
|
||||||
|
const dateFormat = product === 2
|
||||||
|
? currentDay.format('DDMMYYYY')
|
||||||
|
: currentDay.format('YYYY-MM-DD');
|
||||||
timestampPromises.push(
|
timestampPromises.push(
|
||||||
fetchAggregatedDataJson(currentDay.format('YYYY-MM-DD'), s3Credentials)
|
fetchAggregatedDataJson(dateFormat, s3Credentials, product)
|
||||||
);
|
);
|
||||||
currentDay = currentDay.add(1, 'day');
|
currentDay = currentDay.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
|
@ -866,6 +868,16 @@ export const transformInputToAggregatedDataJson = async (
|
||||||
max: overviewData['GridImportPower'].max
|
max: overviewData['GridImportPower'].max
|
||||||
};
|
};
|
||||||
|
|
||||||
|
path = 'LoadPowerConsumption';
|
||||||
|
chartAggregatedData.acLoad.data = data[path];
|
||||||
|
|
||||||
|
chartOverview.ACLoad = {
|
||||||
|
magnitude: overviewData['LoadPowerConsumption'].magnitude,
|
||||||
|
unit: '(kWh)',
|
||||||
|
min: overviewData['LoadPowerConsumption'].min,
|
||||||
|
max: overviewData['LoadPowerConsumption'].max
|
||||||
|
};
|
||||||
|
|
||||||
chartOverview.overview = {
|
chartOverview.overview = {
|
||||||
magnitude: 0,
|
magnitude: 0,
|
||||||
unit: '(kWh)',
|
unit: '(kWh)',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export interface I_Installation extends I_S3Credentials {
|
||||||
testingMode?: boolean;
|
testingMode?: boolean;
|
||||||
status?: number;
|
status?: number;
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
|
networkProvider: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface I_Folder {
|
export interface I_Folder {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"alarms": "Alarme",
|
"alarms": "Alarme",
|
||||||
"applyChanges": "Änderungen speichern",
|
"applyChanges": "Änderungen speichern",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
|
"networkProvider": "Netzbetreiber",
|
||||||
"createNewFolder": "Neuer Ordner",
|
"createNewFolder": "Neuer Ordner",
|
||||||
"createNewUser": "Neuer Benutzer",
|
"createNewUser": "Neuer Benutzer",
|
||||||
"customerName": "Kundenname",
|
"customerName": "Kundenname",
|
||||||
|
|
@ -106,8 +107,8 @@
|
||||||
"daysOfYourUsage": "Tage Ihres Verbrauchs",
|
"daysOfYourUsage": "Tage Ihres Verbrauchs",
|
||||||
"estMoneySaved": "Geschätzte Ersparnisse",
|
"estMoneySaved": "Geschätzte Ersparnisse",
|
||||||
"atCHFRate": "bei 0,39 CHF/kWh Ø",
|
"atCHFRate": "bei 0,39 CHF/kWh Ø",
|
||||||
"solarCoverage": "Eigenversorgung",
|
"solarCoverage": "Energieunabhängigkeit",
|
||||||
"fromSolarSub": "aus Solar + Batterie",
|
"fromSolarSub": "aus eigenem Solar + Batterie System",
|
||||||
"avgDailyConsumption": "Ø Tagesverbrauch",
|
"avgDailyConsumption": "Ø Tagesverbrauch",
|
||||||
"batteryEfficiency": "Batterieeffizienz",
|
"batteryEfficiency": "Batterieeffizienz",
|
||||||
"batteryEffSub": "Entladung vs. Ladung",
|
"batteryEffSub": "Entladung vs. Ladung",
|
||||||
|
|
@ -139,6 +140,22 @@
|
||||||
"weeklyTab": "Wöchentlich",
|
"weeklyTab": "Wöchentlich",
|
||||||
"monthlyTab": "Monatlich",
|
"monthlyTab": "Monatlich",
|
||||||
"yearlyTab": "Jährlich",
|
"yearlyTab": "Jährlich",
|
||||||
|
"dailyTab": "Täglich",
|
||||||
|
"dailyReportTitle": "Tägliche Energieübersicht",
|
||||||
|
"dailySummary": "Tagesübersicht",
|
||||||
|
"selectDate": "Datum wählen",
|
||||||
|
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
|
||||||
|
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
|
||||||
|
"dataUpTo": "Daten bis {date}",
|
||||||
|
"intradayChart": "Tagesverlauf Energiefluss",
|
||||||
|
"batteryPower": "Batterieleistung",
|
||||||
|
"batterySoCLabel": "Batterie SoC",
|
||||||
|
"powerKw": "Leistung (kW)",
|
||||||
|
"socPercent": "SoC (%)",
|
||||||
|
"batteryActivity": "Batterieaktivität",
|
||||||
|
"batteryCharged": "Batterie geladen",
|
||||||
|
"batteryDischarged": "Batterie entladen",
|
||||||
|
"downloadPdf": "PDF herunterladen",
|
||||||
"monthlyReportTitle": "Monatlicher Leistungsbericht",
|
"monthlyReportTitle": "Monatlicher Leistungsbericht",
|
||||||
"yearlyReportTitle": "Jährlicher Leistungsbericht",
|
"yearlyReportTitle": "Jährlicher Leistungsbericht",
|
||||||
"monthlyInsights": "Monatliche Einblicke",
|
"monthlyInsights": "Monatliche Einblicke",
|
||||||
|
|
@ -155,6 +172,7 @@
|
||||||
"availableForGeneration": "Zur Generierung verfügbar",
|
"availableForGeneration": "Zur Generierung verfügbar",
|
||||||
"generateMonth": "{month} {year} generieren ({count} Wochen)",
|
"generateMonth": "{month} {year} generieren ({count} Wochen)",
|
||||||
"generateYear": "{year} generieren ({count} Monate)",
|
"generateYear": "{year} generieren ({count} Monate)",
|
||||||
|
"regenerateReport": "Neu generieren",
|
||||||
"generatingMonthly": "Wird generiert...",
|
"generatingMonthly": "Wird generiert...",
|
||||||
"generatingYearly": "Wird generiert...",
|
"generatingYearly": "Wird generiert...",
|
||||||
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
||||||
|
|
@ -164,9 +182,9 @@
|
||||||
"ai_show_less": "Weniger anzeigen",
|
"ai_show_less": "Weniger anzeigen",
|
||||||
"ai_likely_causes": "Wahrscheinliche Ursachen:",
|
"ai_likely_causes": "Wahrscheinliche Ursachen:",
|
||||||
"ai_next_steps": "Empfohlene nächste Schritte:",
|
"ai_next_steps": "Empfohlene nächste Schritte:",
|
||||||
"demo_test_button": "KI-Diagnose testen",
|
"demo_test_button": "KI-Diagnose",
|
||||||
"demo_hide_button": "KI-Diagnose Demo ausblenden",
|
"demo_hide_button": "KI-Diagnose ausblenden",
|
||||||
"demo_panel_title": "KI-Diagnose Demo",
|
"demo_panel_title": "KI-Diagnose",
|
||||||
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
|
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
|
||||||
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
|
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
|
||||||
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
|
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
|
||||||
|
|
@ -404,5 +422,98 @@
|
||||||
"allInstallations": "Alle Installationen",
|
"allInstallations": "Alle Installationen",
|
||||||
"group": "Gruppe",
|
"group": "Gruppe",
|
||||||
"groups": "Gruppen",
|
"groups": "Gruppen",
|
||||||
"requiredOrderNumber": "Pflichtbestellnummer"
|
"requiredOrderNumber": "Pflichtbestellnummer",
|
||||||
|
"unableToCommunicate": "Kommunikation mit der Installation nicht möglich",
|
||||||
|
"pleaseWaitOrRefresh": "Bitte warten oder Seite aktualisieren",
|
||||||
|
"installationOffline": "Die Installation ist derzeit offline. Letzte verfügbare Daten werden angezeigt.",
|
||||||
|
"noDataForDateRange": "Keine Daten für den gewählten Zeitraum verfügbar. Bitte wählen Sie ein neueres Datum.",
|
||||||
|
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"usernameWrong": "Benutzername ist falsch. Bitte versuchen Sie es erneut.",
|
||||||
|
"mailSentSuccessfully": "E-Mail erfolgreich gesendet.",
|
||||||
|
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
|
||||||
|
"resetPasswordFailed": "Passwort zurücksetzen fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"setNewPasswordFailed": "Neues Passwort setzen fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"successfullyAppliedConfig": "Konfigurationsdatei erfolgreich angewendet",
|
||||||
|
"configErrorOccurred": "Ein Fehler ist aufgetreten",
|
||||||
|
"confirmDeleteFolder": "Möchten Sie diesen Ordner löschen?",
|
||||||
|
"deleteFolderWarning": "Alle Installationen dieses Ordners werden gelöscht.",
|
||||||
|
"failedToLoadReport": "Bericht konnte nicht geladen werden. Stellen Sie sicher, dass die Excel-Datei in tmp_report/ abgelegt ist.",
|
||||||
|
"serverError": "Ein Fehler ist aufgetreten, bitte versuchen Sie es später erneut",
|
||||||
|
"pvViewNotAvailable": "PV-Ansicht ist derzeit nicht verfügbar",
|
||||||
|
"batteryServiceNotAvailable": "Batteriedienst ist derzeit nicht verfügbar",
|
||||||
|
"cannotChangeDateCalibration": "Sie können das Datum nicht ändern, während sich die Installation im Kalibrierungslade-Modus befindet",
|
||||||
|
"mustSpecifyFutureDate": "Sie müssen ein zukünftiges Datum angeben",
|
||||||
|
"valueBetween0And100": "Wert muss zwischen 0-100% liegen",
|
||||||
|
"pleaseProvideValidNumber": "Bitte geben Sie eine gültige Zahl ein",
|
||||||
|
"stopTimeMustBeLater": "Die Stoppzeit muss nach der Startzeit liegen",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"rememberMe": "Angemeldet bleiben",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"close": "Schliessen",
|
||||||
|
"forgotPasswordLink": "Passwort vergessen?",
|
||||||
|
"provideYourUsername": "Geben Sie Ihren Benutzernamen ein",
|
||||||
|
"userName": "Benutzername",
|
||||||
|
"resetPassword": "Passwort zurücksetzen",
|
||||||
|
"setNewPassword": "Neues Passwort setzen",
|
||||||
|
"verifyPassword": "Passwort bestätigen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"successfullyCreatedUser": "Benutzer erfolgreich erstellt",
|
||||||
|
"serverInternalError": "Der Server hat einen internen Fehler festgestellt und konnte Ihre Anfrage nicht abschliessen",
|
||||||
|
"refreshView": "Ansicht aktualisieren",
|
||||||
|
"goBack": "Zurück",
|
||||||
|
"connectingToDevice": "Verbindung zum Gerät wird hergestellt...",
|
||||||
|
"fetchingData": "Daten werden abgerufen...",
|
||||||
|
"confirmDeleteUser": "Möchten Sie diesen Benutzer löschen?",
|
||||||
|
"accessManagement": "Zugriffsverwaltung",
|
||||||
|
"power": "Leistung",
|
||||||
|
"voltage": "Spannung",
|
||||||
|
"current": "Strom",
|
||||||
|
"battery": "Batterie",
|
||||||
|
"firmware": "Firmware",
|
||||||
|
"batteryVoltage": "Batteriespannung",
|
||||||
|
"soc": "Ladezustand",
|
||||||
|
"soh": "Gesundheitszustand",
|
||||||
|
"temperature": "Temperatur",
|
||||||
|
"warnings": "Warnungen",
|
||||||
|
"alarms": "Alarme",
|
||||||
|
"minCellVoltage": "Min. Zellenspannung",
|
||||||
|
"maxCellVoltage": "Max. Zellenspannung",
|
||||||
|
"voltageDifference": "Spannungsdifferenz",
|
||||||
|
"pv": "PV",
|
||||||
|
"showOnly": "Nur anzeigen",
|
||||||
|
"minimumSocPercent": "Minimaler Ladezustand (%)",
|
||||||
|
"powerW": "Leistung (W)",
|
||||||
|
"enterPowerValue": "Positiven oder negativen Leistungswert eingeben",
|
||||||
|
"startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)",
|
||||||
|
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
|
||||||
|
"tourLanguageTitle": "Sprache",
|
||||||
|
"tourLanguageContent": "Wählen Sie Ihre bevorzugte Sprache. Die Oberfläche unterstützt Englisch, Deutsch, Französisch und Italienisch.",
|
||||||
|
"tourExploreTitle": "Installation erkunden",
|
||||||
|
"tourExploreContent": "Klicken Sie auf eine Installation, um sie zu öffnen. Klicken Sie darin erneut auf die Tour-Schaltfläche für eine detaillierte Anleitung aller verfügbaren Tabs.",
|
||||||
|
"tourListTitle": "Installationsliste",
|
||||||
|
"tourListContent": "Suchen und durchsuchen Sie alle Ihre Installationen. Klicken Sie auf eine Installation, um deren Dashboard zu öffnen.",
|
||||||
|
"tourTreeTitle": "Ordneransicht",
|
||||||
|
"tourTreeContent": "Ihre Installationen nach Ordnern organisiert. Erweitern Sie Ordner, um Installationen nach Standort zu finden.",
|
||||||
|
"tourLiveTitle": "Live-Daten",
|
||||||
|
"tourLiveContent": "Echtzeitdaten Ihres Systems — Batteriestatus, Energiefluss und Systemstatus, kontinuierlich aktualisiert.",
|
||||||
|
"tourOverviewTitle": "Überblick",
|
||||||
|
"tourOverviewContent": "Visuelle Zusammenfassung mit Diagrammen — Produktion, Verbrauch und Batterieladung im Zeitverlauf. Verwenden Sie die Datumssteuerung, um einen bestimmten Tag oder Zeitraum anzuzeigen.",
|
||||||
|
"tourBatteryviewTitle": "Batterieansicht",
|
||||||
|
"tourBatteryviewContent": "Detaillierte Batterieüberwachung — Ladezustand (%), Energiefluss (kW), Spannung und Strom pro Batterieeinheit.",
|
||||||
|
"tourPvviewTitle": "PV-Ansicht",
|
||||||
|
"tourPvviewContent": "Solaranlagen-Überwachung — Produktionsdaten Ihrer Photovoltaikanlage.",
|
||||||
|
"tourLogTitle": "Protokoll",
|
||||||
|
"tourLogContent": "Geräte-Ereignisprotokolle — Systemereignisse, Warnungen und Fehler im Zeitverlauf.",
|
||||||
|
"tourInformationTitle": "Systeminformationen",
|
||||||
|
"tourInformationContent": "Installationsdetails — Standort, Geräteseriennummern und Firmware-Versionen. Nutzen Sie dies als Referenz bei Kontakt mit dem Support.",
|
||||||
|
"tourReportTitle": "Energieberichte",
|
||||||
|
"tourReportContent": "Energiedaten in kWh anzeigen. Wechseln Sie zwischen wöchentlichen (Montag–Sonntag), monatlichen und jährlichen Berichten, um zu sehen, wie viel Energie produziert, verbraucht oder gespeichert wurde.",
|
||||||
|
"tourManageTitle": "Zugriffsverwaltung",
|
||||||
|
"tourManageContent": "Verwalten Sie, welche Benutzer Zugriff auf diese Installation haben, und legen Sie deren Berechtigungen fest.",
|
||||||
|
"tourConfigurationTitle": "Konfiguration",
|
||||||
|
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
|
||||||
|
"tourHistoryTitle": "Verlauf",
|
||||||
|
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert."
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"allInstallations": "All installations",
|
"allInstallations": "All installations",
|
||||||
"applyChanges": "Apply changes",
|
"applyChanges": "Apply changes",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
|
"networkProvider": "Network Provider",
|
||||||
"customerName": "Customer name",
|
"customerName": "Customer name",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"german": "German",
|
"german": "German",
|
||||||
|
|
@ -88,8 +89,8 @@
|
||||||
"daysOfYourUsage": "days of your usage",
|
"daysOfYourUsage": "days of your usage",
|
||||||
"estMoneySaved": "Est. Money Saved",
|
"estMoneySaved": "Est. Money Saved",
|
||||||
"atCHFRate": "at 0.39 CHF/kWh avg.",
|
"atCHFRate": "at 0.39 CHF/kWh avg.",
|
||||||
"solarCoverage": "Self-Sufficiency",
|
"solarCoverage": "Energy Independence",
|
||||||
"fromSolarSub": "from solar + battery",
|
"fromSolarSub": "from your own solar + battery system",
|
||||||
"avgDailyConsumption": "Avg Daily Consumption",
|
"avgDailyConsumption": "Avg Daily Consumption",
|
||||||
"batteryEfficiency": "Battery Efficiency",
|
"batteryEfficiency": "Battery Efficiency",
|
||||||
"batteryEffSub": "discharge vs charge",
|
"batteryEffSub": "discharge vs charge",
|
||||||
|
|
@ -121,6 +122,22 @@
|
||||||
"weeklyTab": "Weekly",
|
"weeklyTab": "Weekly",
|
||||||
"monthlyTab": "Monthly",
|
"monthlyTab": "Monthly",
|
||||||
"yearlyTab": "Yearly",
|
"yearlyTab": "Yearly",
|
||||||
|
"dailyTab": "Daily",
|
||||||
|
"dailyReportTitle": "Daily Energy Summary",
|
||||||
|
"dailySummary": "Daily Summary",
|
||||||
|
"selectDate": "Select Date",
|
||||||
|
"noDataForDate": "No data available for the selected date.",
|
||||||
|
"noHourlyData": "Hourly data not available for this day.",
|
||||||
|
"dataUpTo": "Data up to {date}",
|
||||||
|
"intradayChart": "Intraday Power Flow",
|
||||||
|
"batteryPower": "Battery Power",
|
||||||
|
"batterySoCLabel": "Battery SoC",
|
||||||
|
"powerKw": "Power (kW)",
|
||||||
|
"socPercent": "SoC (%)",
|
||||||
|
"batteryActivity": "Battery Activity",
|
||||||
|
"batteryCharged": "Battery Charged",
|
||||||
|
"batteryDischarged": "Battery Discharged",
|
||||||
|
"downloadPdf": "Download PDF",
|
||||||
"monthlyReportTitle": "Monthly Performance Report",
|
"monthlyReportTitle": "Monthly Performance Report",
|
||||||
"yearlyReportTitle": "Annual Performance Report",
|
"yearlyReportTitle": "Annual Performance Report",
|
||||||
"monthlyInsights": "Monthly Insights",
|
"monthlyInsights": "Monthly Insights",
|
||||||
|
|
@ -137,6 +154,7 @@
|
||||||
"availableForGeneration": "Available for Generation",
|
"availableForGeneration": "Available for Generation",
|
||||||
"generateMonth": "Generate {month} {year} ({count} weeks)",
|
"generateMonth": "Generate {month} {year} ({count} weeks)",
|
||||||
"generateYear": "Generate {year} ({count} months)",
|
"generateYear": "Generate {year} ({count} months)",
|
||||||
|
"regenerateReport": "Regenerate",
|
||||||
"generatingMonthly": "Generating...",
|
"generatingMonthly": "Generating...",
|
||||||
"generatingYearly": "Generating...",
|
"generatingYearly": "Generating...",
|
||||||
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
||||||
|
|
@ -146,11 +164,104 @@
|
||||||
"ai_show_less": "Show less",
|
"ai_show_less": "Show less",
|
||||||
"ai_likely_causes": "Likely causes:",
|
"ai_likely_causes": "Likely causes:",
|
||||||
"ai_next_steps": "Suggested next steps:",
|
"ai_next_steps": "Suggested next steps:",
|
||||||
"demo_test_button": "Test AI Diagnosis",
|
"demo_test_button": "AI Diagnosis",
|
||||||
"demo_hide_button": "Hide AI Diagnosis Demo",
|
"demo_hide_button": "Hide AI Diagnosis",
|
||||||
"demo_panel_title": "AI Diagnosis Demo",
|
"demo_panel_title": "AI Diagnosis",
|
||||||
"demo_custom_group": "Custom (may use Mistral AI)",
|
"demo_custom_group": "Custom (may use Mistral AI)",
|
||||||
"demo_custom_option": "Type custom alarm below…",
|
"demo_custom_option": "Type custom alarm below…",
|
||||||
"demo_custom_placeholder": "e.g. UnknownBatteryFault",
|
"demo_custom_placeholder": "e.g. UnknownBatteryFault",
|
||||||
"demo_diagnose_button": "Diagnose"
|
"demo_diagnose_button": "Diagnose",
|
||||||
|
"unableToCommunicate": "Unable to communicate with the installation",
|
||||||
|
"pleaseWaitOrRefresh": "Please wait or refresh the page",
|
||||||
|
"installationOffline": "Installation is currently offline. Showing last available data.",
|
||||||
|
"noDataForDateRange": "No data available for the selected date range. Please choose a more recent date.",
|
||||||
|
"loginFailed": "Login failed. Please try again.",
|
||||||
|
"usernameWrong": "Username is wrong. Please try again.",
|
||||||
|
"mailSentSuccessfully": "Mail sent successfully.",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"resetPasswordFailed": "Reset Password failed. Please try again.",
|
||||||
|
"setNewPasswordFailed": "Setting new password failed. Please try again.",
|
||||||
|
"successfullyAppliedConfig": "Successfully applied configuration file",
|
||||||
|
"configErrorOccurred": "An error has occurred",
|
||||||
|
"confirmDeleteFolder": "Do you want to delete this folder?",
|
||||||
|
"deleteFolderWarning": "All installations of this folder will be deleted.",
|
||||||
|
"failedToLoadReport": "Failed to load report. Make sure the Excel file is placed in tmp_report/",
|
||||||
|
"serverError": "There was an error, please try again later",
|
||||||
|
"pvViewNotAvailable": "Pv view is not available at the moment",
|
||||||
|
"batteryServiceNotAvailable": "Battery service is not available at the moment",
|
||||||
|
"cannotChangeDateCalibration": "You cannot change the date while the installation is in Calibration Charge Mode",
|
||||||
|
"mustSpecifyFutureDate": "You must specify a future date",
|
||||||
|
"valueBetween0And100": "Value should be between 0-100%",
|
||||||
|
"pleaseProvideValidNumber": "Please provide a valid number",
|
||||||
|
"stopTimeMustBeLater": "Stop time must be later than start time",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"login": "Login",
|
||||||
|
"close": "Close",
|
||||||
|
"forgotPasswordLink": "Forgot password?",
|
||||||
|
"provideYourUsername": "Provide your username",
|
||||||
|
"userName": "User Name",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"setNewPassword": "Set New Password",
|
||||||
|
"verifyPassword": "Verify Password",
|
||||||
|
"delete": "Delete",
|
||||||
|
"successfullyCreatedUser": "Successfully Created User",
|
||||||
|
"serverInternalError": "The server encountered an internal error and was not able to complete your request",
|
||||||
|
"refreshView": "Refresh view",
|
||||||
|
"goBack": "Go back",
|
||||||
|
"connectingToDevice": "Connecting to the device...",
|
||||||
|
"fetchingData": "Fetching data...",
|
||||||
|
"confirmDeleteUser": "Do you want to delete this user?",
|
||||||
|
"accessManagement": "Access Management",
|
||||||
|
"power": "Power",
|
||||||
|
"voltage": "Voltage",
|
||||||
|
"current": "Current",
|
||||||
|
"battery": "Battery",
|
||||||
|
"firmware": "Firmware",
|
||||||
|
"batteryVoltage": "Battery Voltage",
|
||||||
|
"soc": "SoC",
|
||||||
|
"soh": "SoH",
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"warnings": "Warnings",
|
||||||
|
"alarms": "Alarms",
|
||||||
|
"minCellVoltage": "Min Cell Voltage",
|
||||||
|
"maxCellVoltage": "Max Cell Voltage",
|
||||||
|
"voltageDifference": "Voltage Difference",
|
||||||
|
"pv": "Pv",
|
||||||
|
"showOnly": "Show Only",
|
||||||
|
"minimumSocPercent": "Minimum SoC (%)",
|
||||||
|
"powerW": "Power (W)",
|
||||||
|
"enterPowerValue": "Enter a positive or negative power value",
|
||||||
|
"startDateTime": "Start Date and Time (Start Time < Stop Time)",
|
||||||
|
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
|
||||||
|
"tourLanguageTitle": "Language",
|
||||||
|
"tourLanguageContent": "Choose your preferred language. The interface supports English, German, French, and Italian.",
|
||||||
|
"tourExploreTitle": "Explore an Installation",
|
||||||
|
"tourExploreContent": "Click any installation to open it. Once inside, click the tour button again for a detailed guide of all available tabs.",
|
||||||
|
"tourListTitle": "Installation List",
|
||||||
|
"tourListContent": "Search and browse all your installations. Click any installation to open its dashboard.",
|
||||||
|
"tourTreeTitle": "Folder View",
|
||||||
|
"tourTreeContent": "Your installations organised in folders. Expand folders to find installations by site or location.",
|
||||||
|
"tourLiveTitle": "Live Data",
|
||||||
|
"tourLiveContent": "Real-time data from your system — battery state, power flow, and system status, updated continuously.",
|
||||||
|
"tourOverviewTitle": "Overview",
|
||||||
|
"tourOverviewContent": "Visual summary with charts — production, consumption, and battery charge over time. Use the date controls to view a specific day or custom range.",
|
||||||
|
"tourBatteryviewTitle": "Battery View",
|
||||||
|
"tourBatteryviewContent": "Detailed battery monitoring — state of charge (%), power flow (kW), voltage, and current per battery unit.",
|
||||||
|
"tourPvviewTitle": "PV View",
|
||||||
|
"tourPvviewContent": "Solar panel monitoring — see production data from your photovoltaic system.",
|
||||||
|
"tourLogTitle": "Log",
|
||||||
|
"tourLogContent": "Device event logs — view system events, warnings, and errors over time.",
|
||||||
|
"tourInformationTitle": "System Information",
|
||||||
|
"tourInformationContent": "Installation details — location, device serial numbers, and firmware versions. Use this as reference if you contact support.",
|
||||||
|
"tourReportTitle": "Energy Reports",
|
||||||
|
"tourReportContent": "View energy data in kWh. Switch between weekly (Monday–Sunday), monthly, and yearly reports to see how much energy was produced, consumed, or stored.",
|
||||||
|
"tourManageTitle": "Access Management",
|
||||||
|
"tourManageContent": "Manage which users have access to this installation and set their permissions.",
|
||||||
|
"tourConfigurationTitle": "Configuration",
|
||||||
|
"tourConfigurationContent": "View and modify device settings for this installation.",
|
||||||
|
"tourHistoryTitle": "History",
|
||||||
|
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"alarms": "Alarmes",
|
"alarms": "Alarmes",
|
||||||
"applyChanges": "Appliquer",
|
"applyChanges": "Appliquer",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
|
"networkProvider": "Gestionnaire de réseau",
|
||||||
"createNewFolder": "Nouveau dossier",
|
"createNewFolder": "Nouveau dossier",
|
||||||
"createNewUser": "Nouvel utilisateur",
|
"createNewUser": "Nouvel utilisateur",
|
||||||
"customerName": "Nom du client",
|
"customerName": "Nom du client",
|
||||||
|
|
@ -100,8 +101,8 @@
|
||||||
"daysOfYourUsage": "jours de votre consommation",
|
"daysOfYourUsage": "jours de votre consommation",
|
||||||
"estMoneySaved": "Économies estimées",
|
"estMoneySaved": "Économies estimées",
|
||||||
"atCHFRate": "à 0,39 CHF/kWh moy.",
|
"atCHFRate": "à 0,39 CHF/kWh moy.",
|
||||||
"solarCoverage": "Autosuffisance",
|
"solarCoverage": "Indépendance énergétique",
|
||||||
"fromSolarSub": "du solaire + batterie",
|
"fromSolarSub": "de votre système solaire + batterie",
|
||||||
"avgDailyConsumption": "Conso. quotidienne moy.",
|
"avgDailyConsumption": "Conso. quotidienne moy.",
|
||||||
"batteryEfficiency": "Efficacité de la batterie",
|
"batteryEfficiency": "Efficacité de la batterie",
|
||||||
"batteryEffSub": "décharge vs charge",
|
"batteryEffSub": "décharge vs charge",
|
||||||
|
|
@ -133,6 +134,22 @@
|
||||||
"weeklyTab": "Hebdomadaire",
|
"weeklyTab": "Hebdomadaire",
|
||||||
"monthlyTab": "Mensuel",
|
"monthlyTab": "Mensuel",
|
||||||
"yearlyTab": "Annuel",
|
"yearlyTab": "Annuel",
|
||||||
|
"dailyTab": "Quotidien",
|
||||||
|
"dailyReportTitle": "Résumé énergétique quotidien",
|
||||||
|
"dailySummary": "Résumé du jour",
|
||||||
|
"selectDate": "Sélectionner la date",
|
||||||
|
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
|
||||||
|
"noHourlyData": "Données horaires non disponibles pour ce jour.",
|
||||||
|
"dataUpTo": "Données jusqu'au {date}",
|
||||||
|
"intradayChart": "Flux d'énergie journalier",
|
||||||
|
"batteryPower": "Puissance batterie",
|
||||||
|
"batterySoCLabel": "SoC batterie",
|
||||||
|
"powerKw": "Puissance (kW)",
|
||||||
|
"socPercent": "SoC (%)",
|
||||||
|
"batteryActivity": "Activité de la batterie",
|
||||||
|
"batteryCharged": "Batterie chargée",
|
||||||
|
"batteryDischarged": "Batterie déchargée",
|
||||||
|
"downloadPdf": "Télécharger PDF",
|
||||||
"monthlyReportTitle": "Rapport de performance mensuel",
|
"monthlyReportTitle": "Rapport de performance mensuel",
|
||||||
"yearlyReportTitle": "Rapport de performance annuel",
|
"yearlyReportTitle": "Rapport de performance annuel",
|
||||||
"monthlyInsights": "Aperçus mensuels",
|
"monthlyInsights": "Aperçus mensuels",
|
||||||
|
|
@ -149,6 +166,7 @@
|
||||||
"availableForGeneration": "Disponible pour génération",
|
"availableForGeneration": "Disponible pour génération",
|
||||||
"generateMonth": "Générer {month} {year} ({count} semaines)",
|
"generateMonth": "Générer {month} {year} ({count} semaines)",
|
||||||
"generateYear": "Générer {year} ({count} mois)",
|
"generateYear": "Générer {year} ({count} mois)",
|
||||||
|
"regenerateReport": "Régénérer",
|
||||||
"generatingMonthly": "Génération en cours...",
|
"generatingMonthly": "Génération en cours...",
|
||||||
"generatingYearly": "Génération en cours...",
|
"generatingYearly": "Génération en cours...",
|
||||||
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
||||||
|
|
@ -158,9 +176,9 @@
|
||||||
"ai_show_less": "Afficher moins",
|
"ai_show_less": "Afficher moins",
|
||||||
"ai_likely_causes": "Causes probables :",
|
"ai_likely_causes": "Causes probables :",
|
||||||
"ai_next_steps": "Prochaines étapes suggérées :",
|
"ai_next_steps": "Prochaines étapes suggérées :",
|
||||||
"demo_test_button": "Tester le diagnostic IA",
|
"demo_test_button": "Diagnostic IA",
|
||||||
"demo_hide_button": "Masquer la démo de diagnostic IA",
|
"demo_hide_button": "Masquer le diagnostic IA",
|
||||||
"demo_panel_title": "Démo de diagnostic IA",
|
"demo_panel_title": "Diagnostic IA",
|
||||||
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
|
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
|
||||||
"demo_custom_option": "Saisir une alarme personnalisée…",
|
"demo_custom_option": "Saisir une alarme personnalisée…",
|
||||||
"demo_custom_placeholder": "ex. UnknownBatteryFault",
|
"demo_custom_placeholder": "ex. UnknownBatteryFault",
|
||||||
|
|
@ -404,5 +422,98 @@
|
||||||
"groupTabs": "Groupes",
|
"groupTabs": "Groupes",
|
||||||
"groupTree": "Arborescence de groupes",
|
"groupTree": "Arborescence de groupes",
|
||||||
"installationTabs": "Installations",
|
"installationTabs": "Installations",
|
||||||
"navigationTabs": "Navigation"
|
"navigationTabs": "Navigation",
|
||||||
|
"unableToCommunicate": "Impossible de communiquer avec l'installation",
|
||||||
|
"pleaseWaitOrRefresh": "Veuillez patienter ou actualiser la page",
|
||||||
|
"installationOffline": "L'installation est actuellement hors ligne. Affichage des dernières données disponibles.",
|
||||||
|
"noDataForDateRange": "Aucune donnée disponible pour la période sélectionnée. Veuillez choisir une date plus récente.",
|
||||||
|
"loginFailed": "Échec de la connexion. Veuillez réessayer.",
|
||||||
|
"usernameWrong": "Nom d'utilisateur incorrect. Veuillez réessayer.",
|
||||||
|
"mailSentSuccessfully": "E-mail envoyé avec succès.",
|
||||||
|
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
|
||||||
|
"resetPasswordFailed": "La réinitialisation du mot de passe a échoué. Veuillez réessayer.",
|
||||||
|
"setNewPasswordFailed": "La définition du nouveau mot de passe a échoué. Veuillez réessayer.",
|
||||||
|
"successfullyAppliedConfig": "Fichier de configuration appliqué avec succès",
|
||||||
|
"configErrorOccurred": "Une erreur s'est produite",
|
||||||
|
"confirmDeleteFolder": "Voulez-vous supprimer ce dossier ?",
|
||||||
|
"deleteFolderWarning": "Toutes les installations de ce dossier seront supprimées.",
|
||||||
|
"failedToLoadReport": "Impossible de charger le rapport. Assurez-vous que le fichier Excel est placé dans tmp_report/",
|
||||||
|
"serverError": "Une erreur s'est produite, veuillez réessayer plus tard",
|
||||||
|
"pvViewNotAvailable": "La vue PV n'est pas disponible pour le moment",
|
||||||
|
"batteryServiceNotAvailable": "Le service batterie n'est pas disponible pour le moment",
|
||||||
|
"cannotChangeDateCalibration": "Vous ne pouvez pas changer la date pendant que l'installation est en mode de charge de calibration",
|
||||||
|
"mustSpecifyFutureDate": "Vous devez spécifier une date future",
|
||||||
|
"valueBetween0And100": "La valeur doit être entre 0-100%",
|
||||||
|
"pleaseProvideValidNumber": "Veuillez fournir un nombre valide",
|
||||||
|
"stopTimeMustBeLater": "L'heure d'arrêt doit être postérieure à l'heure de début",
|
||||||
|
"signIn": "Se connecter",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"rememberMe": "Se souvenir de moi",
|
||||||
|
"login": "Connexion",
|
||||||
|
"close": "Fermer",
|
||||||
|
"forgotPasswordLink": "Mot de passe oublié ?",
|
||||||
|
"provideYourUsername": "Entrez votre nom d'utilisateur",
|
||||||
|
"userName": "Nom d'utilisateur",
|
||||||
|
"resetPassword": "Réinitialiser le mot de passe",
|
||||||
|
"setNewPassword": "Définir un nouveau mot de passe",
|
||||||
|
"verifyPassword": "Vérifier le mot de passe",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"successfullyCreatedUser": "Utilisateur créé avec succès",
|
||||||
|
"serverInternalError": "Le serveur a rencontré une erreur interne et n'a pas pu traiter votre demande",
|
||||||
|
"refreshView": "Actualiser la vue",
|
||||||
|
"goBack": "Retour",
|
||||||
|
"connectingToDevice": "Connexion à l'appareil en cours...",
|
||||||
|
"fetchingData": "Récupération des données...",
|
||||||
|
"confirmDeleteUser": "Voulez-vous supprimer cet utilisateur ?",
|
||||||
|
"accessManagement": "Gestion des accès",
|
||||||
|
"power": "Puissance",
|
||||||
|
"voltage": "Tension",
|
||||||
|
"current": "Courant",
|
||||||
|
"battery": "Batterie",
|
||||||
|
"firmware": "Firmware",
|
||||||
|
"batteryVoltage": "Tension de la batterie",
|
||||||
|
"soc": "État de charge",
|
||||||
|
"soh": "État de santé",
|
||||||
|
"temperature": "Température",
|
||||||
|
"warnings": "Avertissements",
|
||||||
|
"alarms": "Alarmes",
|
||||||
|
"minCellVoltage": "Tension min. cellule",
|
||||||
|
"maxCellVoltage": "Tension max. cellule",
|
||||||
|
"voltageDifference": "Différence de tension",
|
||||||
|
"pv": "PV",
|
||||||
|
"showOnly": "Afficher uniquement",
|
||||||
|
"minimumSocPercent": "SoC minimum (%)",
|
||||||
|
"powerW": "Puissance (W)",
|
||||||
|
"enterPowerValue": "Entrez une valeur de puissance positive ou négative",
|
||||||
|
"startDateTime": "Date et heure de début (Début < Fin)",
|
||||||
|
"stopDateTime": "Date et heure de fin (Début < Fin)",
|
||||||
|
"tourLanguageTitle": "Langue",
|
||||||
|
"tourLanguageContent": "Choisissez votre langue préférée. L'interface est disponible en anglais, allemand, français et italien.",
|
||||||
|
"tourExploreTitle": "Explorer une installation",
|
||||||
|
"tourExploreContent": "Cliquez sur une installation pour l'ouvrir. Une fois à l'intérieur, cliquez à nouveau sur le bouton de visite pour un guide détaillé de tous les onglets disponibles.",
|
||||||
|
"tourListTitle": "Liste des installations",
|
||||||
|
"tourListContent": "Recherchez et parcourez toutes vos installations. Cliquez sur une installation pour ouvrir son tableau de bord.",
|
||||||
|
"tourTreeTitle": "Vue par dossiers",
|
||||||
|
"tourTreeContent": "Vos installations organisées en dossiers. Développez les dossiers pour trouver les installations par site ou emplacement.",
|
||||||
|
"tourLiveTitle": "Données en direct",
|
||||||
|
"tourLiveContent": "Données en temps réel de votre système — état de la batterie, flux d'énergie et état du système, mis à jour en continu.",
|
||||||
|
"tourOverviewTitle": "Aperçu",
|
||||||
|
"tourOverviewContent": "Résumé visuel avec graphiques — production, consommation et charge de la batterie au fil du temps. Utilisez les contrôles de date pour afficher un jour spécifique ou une plage personnalisée.",
|
||||||
|
"tourBatteryviewTitle": "Vue batterie",
|
||||||
|
"tourBatteryviewContent": "Surveillance détaillée de la batterie — état de charge (%), flux d'énergie (kW), tension et courant par unité de batterie.",
|
||||||
|
"tourPvviewTitle": "Vue PV",
|
||||||
|
"tourPvviewContent": "Surveillance des panneaux solaires — consultez les données de production de votre système photovoltaïque.",
|
||||||
|
"tourLogTitle": "Journal",
|
||||||
|
"tourLogContent": "Journaux d'événements — événements système, avertissements et erreurs au fil du temps.",
|
||||||
|
"tourInformationTitle": "Informations système",
|
||||||
|
"tourInformationContent": "Détails de l'installation — emplacement, numéros de série des appareils et versions du firmware. Utilisez ceci comme référence si vous contactez le support.",
|
||||||
|
"tourReportTitle": "Rapports énergétiques",
|
||||||
|
"tourReportContent": "Afficher les données énergétiques en kWh. Basculez entre les rapports hebdomadaires (lundi–dimanche), mensuels et annuels pour voir combien d'énergie a été produite, consommée ou stockée.",
|
||||||
|
"tourManageTitle": "Gestion des accès",
|
||||||
|
"tourManageContent": "Gérez quels utilisateurs ont accès à cette installation et définissez leurs autorisations.",
|
||||||
|
"tourConfigurationTitle": "Configuration",
|
||||||
|
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
|
||||||
|
"tourHistoryTitle": "Historique",
|
||||||
|
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand."
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"allInstallations": "Tutte le installazioni",
|
"allInstallations": "Tutte le installazioni",
|
||||||
"applyChanges": "Applica modifiche",
|
"applyChanges": "Applica modifiche",
|
||||||
"country": "Paese",
|
"country": "Paese",
|
||||||
|
"networkProvider": "Gestore di rete",
|
||||||
"customerName": "Nome cliente",
|
"customerName": "Nome cliente",
|
||||||
"english": "Inglese",
|
"english": "Inglese",
|
||||||
"german": "Tedesco",
|
"german": "Tedesco",
|
||||||
|
|
@ -111,8 +112,8 @@
|
||||||
"daysOfYourUsage": "giorni del tuo consumo",
|
"daysOfYourUsage": "giorni del tuo consumo",
|
||||||
"estMoneySaved": "Risparmio stimato",
|
"estMoneySaved": "Risparmio stimato",
|
||||||
"atCHFRate": "a 0,39 CHF/kWh media",
|
"atCHFRate": "a 0,39 CHF/kWh media",
|
||||||
"solarCoverage": "Autosufficienza",
|
"solarCoverage": "Indipendenza energetica",
|
||||||
"fromSolarSub": "da solare + batteria",
|
"fromSolarSub": "dal proprio impianto solare + batteria",
|
||||||
"avgDailyConsumption": "Consumo medio giornaliero",
|
"avgDailyConsumption": "Consumo medio giornaliero",
|
||||||
"batteryEfficiency": "Efficienza della batteria",
|
"batteryEfficiency": "Efficienza della batteria",
|
||||||
"batteryEffSub": "scarica vs carica",
|
"batteryEffSub": "scarica vs carica",
|
||||||
|
|
@ -144,6 +145,22 @@
|
||||||
"weeklyTab": "Settimanale",
|
"weeklyTab": "Settimanale",
|
||||||
"monthlyTab": "Mensile",
|
"monthlyTab": "Mensile",
|
||||||
"yearlyTab": "Annuale",
|
"yearlyTab": "Annuale",
|
||||||
|
"dailyTab": "Giornaliero",
|
||||||
|
"dailyReportTitle": "Riepilogo energetico giornaliero",
|
||||||
|
"dailySummary": "Riepilogo del giorno",
|
||||||
|
"selectDate": "Seleziona data",
|
||||||
|
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
|
||||||
|
"noHourlyData": "Dati orari non disponibili per questo giorno.",
|
||||||
|
"dataUpTo": "Dati fino al {date}",
|
||||||
|
"intradayChart": "Flusso energetico giornaliero",
|
||||||
|
"batteryPower": "Potenza batteria",
|
||||||
|
"batterySoCLabel": "SoC batteria",
|
||||||
|
"powerKw": "Potenza (kW)",
|
||||||
|
"socPercent": "SoC (%)",
|
||||||
|
"batteryActivity": "Attività della batteria",
|
||||||
|
"batteryCharged": "Batteria caricata",
|
||||||
|
"batteryDischarged": "Batteria scaricata",
|
||||||
|
"downloadPdf": "Scarica PDF",
|
||||||
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
|
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
|
||||||
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
|
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
|
||||||
"monthlyInsights": "Approfondimenti mensili",
|
"monthlyInsights": "Approfondimenti mensili",
|
||||||
|
|
@ -160,6 +177,7 @@
|
||||||
"availableForGeneration": "Disponibile per la generazione",
|
"availableForGeneration": "Disponibile per la generazione",
|
||||||
"generateMonth": "Genera {month} {year} ({count} settimane)",
|
"generateMonth": "Genera {month} {year} ({count} settimane)",
|
||||||
"generateYear": "Genera {year} ({count} mesi)",
|
"generateYear": "Genera {year} ({count} mesi)",
|
||||||
|
"regenerateReport": "Rigenera",
|
||||||
"generatingMonthly": "Generazione in corso...",
|
"generatingMonthly": "Generazione in corso...",
|
||||||
"generatingYearly": "Generazione in corso...",
|
"generatingYearly": "Generazione in corso...",
|
||||||
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
||||||
|
|
@ -169,9 +187,9 @@
|
||||||
"ai_show_less": "Mostra meno",
|
"ai_show_less": "Mostra meno",
|
||||||
"ai_likely_causes": "Cause probabili:",
|
"ai_likely_causes": "Cause probabili:",
|
||||||
"ai_next_steps": "Passi successivi suggeriti:",
|
"ai_next_steps": "Passi successivi suggeriti:",
|
||||||
"demo_test_button": "Testa diagnosi IA",
|
"demo_test_button": "Diagnosi IA",
|
||||||
"demo_hide_button": "Nascondi demo diagnosi IA",
|
"demo_hide_button": "Nascondi diagnosi IA",
|
||||||
"demo_panel_title": "Demo diagnosi IA",
|
"demo_panel_title": "Diagnosi IA",
|
||||||
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
|
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
|
||||||
"demo_custom_option": "Inserisci allarme personalizzato…",
|
"demo_custom_option": "Inserisci allarme personalizzato…",
|
||||||
"demo_custom_placeholder": "es. UnknownBatteryFault",
|
"demo_custom_placeholder": "es. UnknownBatteryFault",
|
||||||
|
|
@ -404,5 +422,98 @@
|
||||||
"alarm_AFCIFault": "Guasto AFCI",
|
"alarm_AFCIFault": "Guasto AFCI",
|
||||||
"alarm_GFCIHigh": "Corrente di guasto a terra elevata",
|
"alarm_GFCIHigh": "Corrente di guasto a terra elevata",
|
||||||
"alarm_PVVoltageHigh": "Tensione PV elevata",
|
"alarm_PVVoltageHigh": "Tensione PV elevata",
|
||||||
"alarm_OffGridBusVoltageTooLow": "Tensione Bus Fuori Rete Troppo Bassa"
|
"alarm_OffGridBusVoltageTooLow": "Tensione Bus Fuori Rete Troppo Bassa",
|
||||||
|
"unableToCommunicate": "Impossibile comunicare con l'installazione",
|
||||||
|
"pleaseWaitOrRefresh": "Attendere o aggiornare la pagina",
|
||||||
|
"installationOffline": "L'installazione è attualmente offline. Vengono mostrati gli ultimi dati disponibili.",
|
||||||
|
"noDataForDateRange": "Nessun dato disponibile per il periodo selezionato. Scegliere una data più recente.",
|
||||||
|
"loginFailed": "Accesso fallito. Riprovare.",
|
||||||
|
"usernameWrong": "Nome utente errato. Riprovare.",
|
||||||
|
"mailSentSuccessfully": "E-mail inviata con successo.",
|
||||||
|
"passwordsDoNotMatch": "Le password non corrispondono",
|
||||||
|
"resetPasswordFailed": "Reimpostazione password fallita. Riprovare.",
|
||||||
|
"setNewPasswordFailed": "Impostazione nuova password fallita. Riprovare.",
|
||||||
|
"successfullyAppliedConfig": "File di configurazione applicato con successo",
|
||||||
|
"configErrorOccurred": "Si è verificato un errore",
|
||||||
|
"confirmDeleteFolder": "Vuoi eliminare questa cartella?",
|
||||||
|
"deleteFolderWarning": "Tutte le installazioni di questa cartella verranno eliminate.",
|
||||||
|
"failedToLoadReport": "Impossibile caricare il rapporto. Assicurarsi che il file Excel sia in tmp_report/",
|
||||||
|
"serverError": "Si è verificato un errore, riprovare più tardi",
|
||||||
|
"pvViewNotAvailable": "La vista PV non è disponibile al momento",
|
||||||
|
"batteryServiceNotAvailable": "Il servizio batteria non è disponibile al momento",
|
||||||
|
"cannotChangeDateCalibration": "Non è possibile cambiare la data mentre l'installazione è in modalità di carica di calibrazione",
|
||||||
|
"mustSpecifyFutureDate": "Specificare una data futura",
|
||||||
|
"valueBetween0And100": "Il valore deve essere tra 0-100%",
|
||||||
|
"pleaseProvideValidNumber": "Inserire un numero valido",
|
||||||
|
"stopTimeMustBeLater": "L'ora di fine deve essere successiva all'ora di inizio",
|
||||||
|
"signIn": "Accedi",
|
||||||
|
"username": "Nome utente",
|
||||||
|
"password": "Password",
|
||||||
|
"rememberMe": "Ricordami",
|
||||||
|
"login": "Accedi",
|
||||||
|
"close": "Chiudi",
|
||||||
|
"forgotPasswordLink": "Password dimenticata?",
|
||||||
|
"provideYourUsername": "Inserisci il tuo nome utente",
|
||||||
|
"userName": "Nome utente",
|
||||||
|
"resetPassword": "Reimposta password",
|
||||||
|
"setNewPassword": "Imposta nuova password",
|
||||||
|
"verifyPassword": "Verifica password",
|
||||||
|
"delete": "Elimina",
|
||||||
|
"successfullyCreatedUser": "Utente creato con successo",
|
||||||
|
"serverInternalError": "Il server ha riscontrato un errore interno e non è stato in grado di completare la richiesta",
|
||||||
|
"refreshView": "Aggiorna vista",
|
||||||
|
"goBack": "Indietro",
|
||||||
|
"connectingToDevice": "Connessione al dispositivo in corso...",
|
||||||
|
"fetchingData": "Recupero dati in corso...",
|
||||||
|
"confirmDeleteUser": "Vuoi eliminare questo utente?",
|
||||||
|
"accessManagement": "Gestione accessi",
|
||||||
|
"power": "Potenza",
|
||||||
|
"voltage": "Tensione",
|
||||||
|
"current": "Corrente",
|
||||||
|
"battery": "Batteria",
|
||||||
|
"firmware": "Firmware",
|
||||||
|
"batteryVoltage": "Tensione batteria",
|
||||||
|
"soc": "Stato di carica",
|
||||||
|
"soh": "Stato di salute",
|
||||||
|
"temperature": "Temperatura",
|
||||||
|
"warnings": "Avvisi",
|
||||||
|
"alarms": "Allarmi",
|
||||||
|
"minCellVoltage": "Tensione min. cella",
|
||||||
|
"maxCellVoltage": "Tensione max. cella",
|
||||||
|
"voltageDifference": "Differenza di tensione",
|
||||||
|
"pv": "PV",
|
||||||
|
"showOnly": "Mostra solo",
|
||||||
|
"minimumSocPercent": "SoC minimo (%)",
|
||||||
|
"powerW": "Potenza (W)",
|
||||||
|
"enterPowerValue": "Inserire un valore di potenza positivo o negativo",
|
||||||
|
"startDateTime": "Data e ora di inizio (Inizio < Fine)",
|
||||||
|
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
|
||||||
|
"tourLanguageTitle": "Lingua",
|
||||||
|
"tourLanguageContent": "Scegli la tua lingua preferita. L'interfaccia supporta inglese, tedesco, francese e italiano.",
|
||||||
|
"tourExploreTitle": "Esplora un'installazione",
|
||||||
|
"tourExploreContent": "Clicca su un'installazione per aprirla. Una volta dentro, clicca nuovamente sul pulsante del tour per una guida dettagliata di tutte le schede disponibili.",
|
||||||
|
"tourListTitle": "Elenco installazioni",
|
||||||
|
"tourListContent": "Cerca e sfoglia tutte le tue installazioni. Clicca su un'installazione per aprire la sua dashboard.",
|
||||||
|
"tourTreeTitle": "Vista cartelle",
|
||||||
|
"tourTreeContent": "Le tue installazioni organizzate in cartelle. Espandi le cartelle per trovare le installazioni per sito o posizione.",
|
||||||
|
"tourLiveTitle": "Dati in tempo reale",
|
||||||
|
"tourLiveContent": "Dati in tempo reale dal tuo sistema — stato della batteria, flusso di energia e stato del sistema, aggiornati continuamente.",
|
||||||
|
"tourOverviewTitle": "Panoramica",
|
||||||
|
"tourOverviewContent": "Riepilogo visivo con grafici — produzione, consumo e carica della batteria nel tempo. Usa i controlli della data per visualizzare un giorno specifico o un intervallo personalizzato.",
|
||||||
|
"tourBatteryviewTitle": "Vista batteria",
|
||||||
|
"tourBatteryviewContent": "Monitoraggio dettagliato della batteria — stato di carica (%), flusso di energia (kW), tensione e corrente per unità di batteria.",
|
||||||
|
"tourPvviewTitle": "Vista PV",
|
||||||
|
"tourPvviewContent": "Monitoraggio dei pannelli solari — visualizza i dati di produzione del tuo impianto fotovoltaico.",
|
||||||
|
"tourLogTitle": "Registro",
|
||||||
|
"tourLogContent": "Registri degli eventi — eventi di sistema, avvisi ed errori nel tempo.",
|
||||||
|
"tourInformationTitle": "Informazioni di sistema",
|
||||||
|
"tourInformationContent": "Dettagli dell'installazione — posizione, numeri di serie dei dispositivi e versioni firmware. Usa come riferimento se contatti l'assistenza.",
|
||||||
|
"tourReportTitle": "Rapporti energetici",
|
||||||
|
"tourReportContent": "Visualizza i dati energetici in kWh. Passa tra rapporti settimanali (lunedì–domenica), mensili e annuali per vedere quanta energia è stata prodotta, consumata o immagazzinata.",
|
||||||
|
"tourManageTitle": "Gestione accessi",
|
||||||
|
"tourManageContent": "Gestisci quali utenti hanno accesso a questa installazione e imposta i loro permessi.",
|
||||||
|
"tourConfigurationTitle": "Configurazione",
|
||||||
|
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
|
||||||
|
"tourHistoryTitle": "Cronologia",
|
||||||
|
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ function HeaderMenu(props: HeaderButtonsProps) {
|
||||||
sx={{
|
sx={{
|
||||||
color: isMobile ? 'white' : ''
|
color: isMobile ? 'white' : ''
|
||||||
}}
|
}}
|
||||||
|
data-tour="language-selector"
|
||||||
>
|
>
|
||||||
<List disablePadding component={Box} display="flex">
|
<List disablePadding component={Box} display="flex">
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,12 @@ import {
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import MenuTwoToneIcon from '@mui/icons-material/MenuTwoTone';
|
import MenuTwoToneIcon from '@mui/icons-material/MenuTwoTone';
|
||||||
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||||
import { SidebarContext } from 'src/contexts/SidebarContext';
|
import { SidebarContext } from 'src/contexts/SidebarContext';
|
||||||
import CloseTwoToneIcon from '@mui/icons-material/CloseTwoTone';
|
import CloseTwoToneIcon from '@mui/icons-material/CloseTwoTone';
|
||||||
import HeaderUserbox from './Userbox';
|
import HeaderUserbox from './Userbox';
|
||||||
import HeaderMenu from './Menu';
|
import HeaderMenu from './Menu';
|
||||||
|
import { useTour } from 'src/contexts/TourContext';
|
||||||
|
|
||||||
const HeaderWrapper = styled(Box)(
|
const HeaderWrapper = styled(Box)(
|
||||||
({ theme }) => `
|
({ theme }) => `
|
||||||
|
|
@ -44,6 +46,7 @@ interface HeaderProps {
|
||||||
|
|
||||||
function Header(props: HeaderProps) {
|
function Header(props: HeaderProps) {
|
||||||
const { sidebarToggle, toggleSidebar } = useContext(SidebarContext);
|
const { sidebarToggle, toggleSidebar } = useContext(SidebarContext);
|
||||||
|
const { startTour } = useTour();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = window.innerWidth <= 1280;
|
const isMobile = window.innerWidth <= 1280;
|
||||||
|
|
||||||
|
|
@ -96,6 +99,14 @@ function Header(props: HeaderProps) {
|
||||||
></Stack>
|
></Stack>
|
||||||
|
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
|
<Tooltip arrow title="Take a Tour">
|
||||||
|
<IconButton
|
||||||
|
sx={{ color: isMobile ? 'white' : theme.header.textColor }}
|
||||||
|
onClick={startTour}
|
||||||
|
>
|
||||||
|
<HelpOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
<HeaderMenu
|
<HeaderMenu
|
||||||
language={props.language}
|
language={props.language}
|
||||||
onSelectLanguage={props.onSelectLanguage}
|
onSelectLanguage={props.onSelectLanguage}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode, useContext, useEffect, useState } from 'react';
|
||||||
import { alpha, Box, lighten, useTheme } from '@mui/material';
|
import { alpha, Box, lighten, useTheme } from '@mui/material';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride';
|
||||||
|
import { useIntl, IntlShape } from 'react-intl';
|
||||||
|
import { useTour } from 'src/contexts/TourContext';
|
||||||
|
import { UserContext } from 'src/contexts/userContext';
|
||||||
|
import { UserType } from 'src/interfaces/UserTypes';
|
||||||
|
import {
|
||||||
|
buildSodiohomeCustomerTourSteps, buildSodiohomePartnerTourSteps, buildSodiohomeAdminTourSteps,
|
||||||
|
buildSalimaxCustomerTourSteps, buildSalimaxPartnerTourSteps, buildSalimaxAdminTourSteps,
|
||||||
|
buildSodistoregridCustomerTourSteps, buildSodistoregridPartnerTourSteps, buildSodistoregridAdminTourSteps,
|
||||||
|
buildSalidomoCustomerTourSteps, buildSalidomoPartnerTourSteps, buildSalidomoAdminTourSteps
|
||||||
|
} from 'src/config/tourSteps';
|
||||||
|
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
|
|
@ -11,11 +22,88 @@ interface SidebarLayoutProps {
|
||||||
onSelectLanguage: (item: string) => void;
|
onSelectLanguage: (item: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTourSteps(pathname: string, userType: UserType, intl: IntlShape, isInsideInstallation: boolean): Step[] {
|
||||||
|
const role = userType === UserType.admin ? 'admin'
|
||||||
|
: userType === UserType.partner ? 'partner'
|
||||||
|
: 'customer';
|
||||||
|
|
||||||
|
if (pathname.includes('/sodiohome_installations')) {
|
||||||
|
if (role === 'admin') return buildSodiohomeAdminTourSteps(intl, isInsideInstallation);
|
||||||
|
if (role === 'partner') return buildSodiohomePartnerTourSteps(intl, isInsideInstallation);
|
||||||
|
return buildSodiohomeCustomerTourSteps(intl, isInsideInstallation);
|
||||||
|
}
|
||||||
|
if (pathname.includes('/salidomo_installations')) {
|
||||||
|
if (role === 'admin') return buildSalidomoAdminTourSteps(intl, isInsideInstallation);
|
||||||
|
if (role === 'partner') return buildSalidomoPartnerTourSteps(intl, isInsideInstallation);
|
||||||
|
return buildSalidomoCustomerTourSteps(intl, isInsideInstallation);
|
||||||
|
}
|
||||||
|
if (pathname.includes('/sodistoregrid_installations')) {
|
||||||
|
if (role === 'admin') return buildSodistoregridAdminTourSteps(intl, isInsideInstallation);
|
||||||
|
if (role === 'partner') return buildSodistoregridPartnerTourSteps(intl, isInsideInstallation);
|
||||||
|
return buildSodistoregridCustomerTourSteps(intl, isInsideInstallation);
|
||||||
|
}
|
||||||
|
// Salimax (/installations/) and Sodistore Max (/sodistore_installations/)
|
||||||
|
if (role === 'admin') return buildSalimaxAdminTourSteps(intl, isInsideInstallation);
|
||||||
|
if (role === 'partner') return buildSalimaxPartnerTourSteps(intl, isInsideInstallation);
|
||||||
|
return buildSalimaxCustomerTourSteps(intl, isInsideInstallation);
|
||||||
|
}
|
||||||
|
|
||||||
const SidebarLayout = (props: SidebarLayoutProps) => {
|
const SidebarLayout = (props: SidebarLayoutProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
|
const { runTour, stopTour } = useTour();
|
||||||
|
const location = useLocation();
|
||||||
|
const { currentUser } = useContext(UserContext);
|
||||||
|
const [tourSteps, setTourSteps] = useState<Step[]>([]);
|
||||||
|
const [tourReady, setTourReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runTour) {
|
||||||
|
setTourReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Delay to let child components render their tour target elements
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const userType = currentUser?.userType ?? UserType.client;
|
||||||
|
const isInsideInstallation = location.pathname.includes('/installation/');
|
||||||
|
const steps = getTourSteps(location.pathname, userType, intl, isInsideInstallation);
|
||||||
|
const filtered = steps.filter((step) => {
|
||||||
|
if (typeof step.target === 'string') {
|
||||||
|
return document.querySelector(step.target) !== null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setTourSteps(filtered);
|
||||||
|
setTourReady(true);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [runTour, location.pathname, currentUser?.userType, intl]);
|
||||||
|
|
||||||
|
const handleJoyrideCallback = (data: CallBackProps) => {
|
||||||
|
const { status } = data;
|
||||||
|
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||||
|
stopTour();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Joyride
|
||||||
|
steps={tourSteps}
|
||||||
|
run={tourReady}
|
||||||
|
callback={handleJoyrideCallback}
|
||||||
|
continuous
|
||||||
|
showSkipButton
|
||||||
|
showProgress
|
||||||
|
scrollToFirstStep
|
||||||
|
disableOverlayClose
|
||||||
|
styles={{
|
||||||
|
options: {
|
||||||
|
primaryColor: '#ffc04d',
|
||||||
|
zIndex: 10000
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue