Compare commits
No commits in common. "69148410f214c39c352214c7c383b7e89e5c5104" and "662810db988d1f23400d913a6992df68207ef0f1" have entirely different histories.
69148410f2
...
662810db98
|
|
@ -752,17 +752,7 @@ public class Controller : ControllerBase
|
|||
|
||||
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))]
|
||||
public ActionResult AcknowledgeError(Int64 id, Token authToken)
|
||||
{
|
||||
|
|
@ -922,13 +912,12 @@ public class Controller : ControllerBase
|
|||
// ── Weekly Performance Report ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a weekly performance report. Serves from cache if available;
|
||||
/// generates fresh on first request or when forceRegenerate is true.
|
||||
/// Generates a weekly performance report from an Excel file in tmp_report/{installationId}.xlsx
|
||||
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
|
||||
/// </summary>
|
||||
[HttpGet(nameof(GetWeeklyReport))]
|
||||
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
|
||||
Int64 installationId, Token authToken, String? language = null,
|
||||
String? weekStart = null, Boolean forceRegenerate = false)
|
||||
Int64 installationId, Token authToken, String? language = null, String? weekStart = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
|
|
@ -949,42 +938,9 @@ public class Controller : ControllerBase
|
|||
|
||||
try
|
||||
{
|
||||
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 lang = language ?? user.Language ?? "en";
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installationId, installation.Name, lang, weekStartDate);
|
||||
installationId, installation.InstallationName, lang, weekStartDate);
|
||||
|
||||
// Persist weekly summary and seed AiInsightCache for this language
|
||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||
|
|
@ -1015,8 +971,8 @@ public class Controller : ControllerBase
|
|||
try
|
||||
{
|
||||
var lang = user.Language ?? "en";
|
||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
|
||||
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang, user.Name);
|
||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
|
||||
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang);
|
||||
return Ok(new { message = $"Report sent to {emailAddress}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -1235,153 +1191,6 @@ public class Controller : ControllerBase
|
|||
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>
|
||||
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
|
||||
/// Safe to use during testing — only removes daily records, not report summaries.
|
||||
|
|
@ -1438,7 +1247,7 @@ public class Controller : ControllerBase
|
|||
{
|
||||
var lang = user.Language ?? "en";
|
||||
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
|
||||
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.Name, emailAddress, lang, user.Name);
|
||||
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
||||
return Ok(new { message = $"Monthly report sent to {emailAddress}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -1467,7 +1276,7 @@ public class Controller : ControllerBase
|
|||
{
|
||||
var lang = user.Language ?? "en";
|
||||
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
|
||||
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.Name, emailAddress, lang, user.Name);
|
||||
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
||||
return Ok(new { message = $"Yearly report sent to {emailAddress}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -1937,171 +1746,7 @@ public class Controller : ControllerBase
|
|||
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,5 +54,4 @@ public class Installation : TreeNode
|
|||
public String OrderNumbers { get; set; }
|
||||
public String VrmLink { 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)
|
||||
{
|
||||
|
||||
var maxRetransmissions = 6;
|
||||
var maxRetransmissions = 4;
|
||||
UdpClient udpClient = new UdpClient();
|
||||
udpClient.Client.ReceiveTimeout = 3000;
|
||||
udpClient.Client.ReceiveTimeout = 2000;
|
||||
int port = 9000;
|
||||
|
||||
Console.WriteLine("Trying to reach installation with IP: " + installation.VpnIp);
|
||||
|
|
|
|||
|
|
@ -43,12 +43,6 @@ public class WeeklyReportSummary
|
|||
// AI insight for this week
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -74,12 +74,6 @@ public static partial class Db
|
|||
public static Boolean Create(DailyEnergyRecord record) => Insert(record);
|
||||
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,12 +32,6 @@ public static partial class Db
|
|||
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
||||
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()
|
||||
{
|
||||
|
|
@ -69,12 +63,6 @@ public static partial class Db
|
|||
Connection.CreateTable<DailyEnergyRecord>();
|
||||
Connection.CreateTable<HourlyEnergyRecord>();
|
||||
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
|
||||
|
|
@ -124,12 +112,6 @@ public static partial class Db
|
|||
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||
fileConnection.CreateTable<AiInsightCache>();
|
||||
|
||||
// Ticket system tables
|
||||
fileConnection.CreateTable<Ticket>();
|
||||
fileConnection.CreateTable<TicketComment>();
|
||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
return fileConnection;
|
||||
//return CopyDbToMemory(fileConnection);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,17 +124,6 @@ public static partial class Db
|
|||
Warnings .Delete(w => w.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -210,21 +199,6 @@ public static partial class Db
|
|||
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>
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -77,18 +77,6 @@ public static partial class Db
|
|||
.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)
|
||||
=> MonthlyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
|
|
@ -116,9 +104,13 @@ public static partial class Db
|
|||
{
|
||||
var fromStr = from.ToString("yyyy-MM-dd");
|
||||
var toStr = to.ToString("yyyy-MM-dd");
|
||||
return Connection.Query<DailyEnergyRecord>(
|
||||
"SELECT * FROM DailyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date",
|
||||
installationId, fromStr, toStr);
|
||||
return DailyRecords
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
|
||||
.OrderBy(r => r.Date)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -137,9 +129,13 @@ public static partial class Db
|
|||
{
|
||||
var fromStr = from.ToString("yyyy-MM-dd");
|
||||
var toStr = to.ToString("yyyy-MM-dd");
|
||||
return Connection.Query<HourlyEnergyRecord>(
|
||||
"SELECT * FROM HourlyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date, Hour",
|
||||
installationId, fromStr, toStr);
|
||||
return HourlyRecords
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.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>
|
||||
|
|
@ -160,33 +156,4 @@ public static partial class Db
|
|||
&& c.ReportId == reportId
|
||||
&& c.Language == language)
|
||||
?.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();
|
||||
}
|
||||
|
|
@ -49,27 +49,11 @@ public static partial class Db
|
|||
public static void UpdateAction(UserAction updatedAction)
|
||||
{
|
||||
var existingAction = UserActions.FirstOrDefault(action => action.Id == updatedAction.Id);
|
||||
|
||||
|
||||
if (existingAction != null)
|
||||
{
|
||||
Update(updatedAction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,8 +27,6 @@ public static class Program
|
|||
Db.Init();
|
||||
LoadEnvFile();
|
||||
DiagnosticService.Initialize();
|
||||
TicketDiagnosticService.Initialize();
|
||||
NetworkProviderService.Initialize();
|
||||
AlarmReviewService.StartDailyScheduler();
|
||||
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
||||
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
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,8 +4,12 @@ using InnovEnergy.App.Backend.DataTypes;
|
|||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
|
||||
/// Data source priority: JSON (local) → JSON (S3) → xlsx fallback.
|
||||
/// Ingests daily energy totals from xlsx files into the DailyEnergyRecord SQLite table.
|
||||
/// This is the source-of-truth population step for the report pipeline.
|
||||
///
|
||||
/// 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
|
||||
/// IngestDailyData API endpoint.
|
||||
/// </summary>
|
||||
|
|
@ -14,9 +18,6 @@ public static class DailyIngestionService
|
|||
private static readonly String TmpReportDir =
|
||||
Environment.CurrentDirectory + "/tmp_report/";
|
||||
|
||||
private static readonly String JsonAggregatedDir =
|
||||
Environment.CurrentDirectory + "/tmp_report/aggregated/";
|
||||
|
||||
private static Timer? _dailyTimer;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -72,119 +73,11 @@ public static class DailyIngestionService
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests data for one installation. Tries JSON (local + S3) and xlsx.
|
||||
/// Both sources are tried — idempotency checks prevent duplicates.
|
||||
/// JSON provides recent data; xlsx provides historical data.
|
||||
/// Parses all xlsx files matching {installationId}*.xlsx in tmp_report/ and stores
|
||||
/// any new days as DailyEnergyRecord rows. Supports multiple time-ranged files per
|
||||
/// installation (e.g. 123_0203_0803.xlsx, 123_0901_1231.xlsx). Idempotent.
|
||||
/// </summary>
|
||||
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))
|
||||
{
|
||||
|
|
@ -205,8 +98,12 @@ public static class DailyIngestionService
|
|||
|
||||
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
|
||||
{
|
||||
// Ingest daily records
|
||||
List<DailyEnergyData> days;
|
||||
try { days = ExcelDataParser.Parse(xlsxPath); }
|
||||
try
|
||||
{
|
||||
days = ExcelDataParser.Parse(xlsxPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||
|
|
@ -235,8 +132,12 @@ public static class DailyIngestionService
|
|||
newDailyCount++;
|
||||
}
|
||||
|
||||
// Ingest hourly records
|
||||
List<HourlyEnergyData> hours;
|
||||
try { hours = ExcelDataParser.ParseHourly(xlsxPath); }
|
||||
try
|
||||
{
|
||||
hours = ExcelDataParser.ParseHourly(xlsxPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||
|
|
@ -269,6 +170,7 @@ public static class DailyIngestionService
|
|||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Installation {installationId} (xlsx): {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
|
||||
Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
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,29 +185,6 @@ public static class ReportAggregationService
|
|||
foreach (var old in overlapping)
|
||||
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
|
||||
{
|
||||
InstallationId = installationId,
|
||||
|
|
@ -230,7 +207,6 @@ public static class ReportAggregationService
|
|||
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
|
||||
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
|
||||
AiInsight = report.AiInsight,
|
||||
ResponseJson = JsonConvert.SerializeObject(reportForCache),
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
|
|
@ -402,8 +378,7 @@ public static class ReportAggregationService
|
|||
installationName, monthName, days.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language,
|
||||
installation?.Location, installation?.Country, installation?.Region);
|
||||
selfSufficiency, batteryEff, language);
|
||||
|
||||
var monthlySummary = new MonthlyReportSummary
|
||||
{
|
||||
|
|
@ -598,31 +573,11 @@ public static class ReportAggregationService
|
|||
() => 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>
|
||||
public static Task<String> GetOrGenerateMonthlyInsightAsync(
|
||||
MonthlyReportSummary report, String language)
|
||||
{
|
||||
var installation = Db.GetInstallationById(report.InstallationId);
|
||||
var installationName = installation?.InstallationName
|
||||
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
|
||||
?? $"Installation {report.InstallationId}";
|
||||
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
|
||||
return GetOrGenerateInsightAsync("monthly", report.Id, language,
|
||||
|
|
@ -632,8 +587,7 @@ public static class ReportAggregationService
|
|||
report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
|
||||
installation?.Location, installation?.Country, installation?.Region));
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
|
||||
|
|
@ -716,8 +670,7 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
|
|||
Double totalBattChg, Double totalBattDis,
|
||||
Double energySaved, Double savingsCHF,
|
||||
Double selfSufficiency, Double batteryEff,
|
||||
String language = "en",
|
||||
String? location = null, String? country = null, String? region = null)
|
||||
String language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
|
|
@ -728,34 +681,24 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
|
|||
// Determine which metric is weakest so the tip can be targeted
|
||||
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}"".
|
||||
|
||||
Write a concise monthly performance summary in {langName} (5 bullet points, plain text, no markdown).
|
||||
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
|
||||
|
||||
MONTHLY FACTS for {monthName} ({weekCount} days of data):
|
||||
- PV production: {totalPv:F1} kWh
|
||||
- Total consumption: {totalConsump:F1} kWh
|
||||
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar + battery, not bought from grid)
|
||||
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid)
|
||||
- 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)
|
||||
{weatherBlock}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Savings: state exactly how much energy and money was saved this month. Positive framing.
|
||||
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. 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. 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.
|
||||
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.
|
||||
3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention.
|
||||
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.
|
||||
|
||||
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.";
|
||||
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.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
|
@ -775,7 +718,7 @@ Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisk
|
|||
var langName = GetLanguageName(language);
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write a concise annual performance summary in {langName} (5 bullet points, plain text, no markdown).
|
||||
Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown).
|
||||
|
||||
ANNUAL FACTS for {year} ({monthCount} months of data):
|
||||
- Total PV production: {totalPv:F1} kWh
|
||||
|
|
@ -788,12 +731,11 @@ ANNUAL FACTS for {year} ({monthCount} months of data):
|
|||
|
||||
INSTRUCTIONS:
|
||||
1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided.
|
||||
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. Solar production: state total PV production for the year. Keep it factual. Do NOT repeat self-sufficiency percentage here.
|
||||
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.
|
||||
2. System performance: comment on PV production and battery health indicators.
|
||||
3. Year-over-year readiness: note any trends or areas of improvement.
|
||||
4. 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 5 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 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||
|
||||
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.
|
||||
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
|
||||
/// </summary>
|
||||
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en", string customerName = null)
|
||||
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en")
|
||||
{
|
||||
var strings = GetStrings(language);
|
||||
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
|
||||
var html = BuildHtmlEmail(report, strings, customerName);
|
||||
var html = BuildHtmlEmail(report, strings);
|
||||
|
||||
var config = await ReadMailerConfig();
|
||||
|
||||
|
|
@ -80,8 +80,7 @@ public static class ReportEmailService
|
|||
string GridIn,
|
||||
string GridOut,
|
||||
string BattInOut,
|
||||
string Footer,
|
||||
string FooterLink
|
||||
string Footer
|
||||
);
|
||||
|
||||
private static EmailStrings GetStrings(string language) => language switch
|
||||
|
|
@ -105,8 +104,8 @@ public static class ReportEmailService
|
|||
StayedAtHome: "Solar + Batterie, nicht vom Netz",
|
||||
EstMoneySaved: "Geschätzte Ersparnis",
|
||||
AtRate: "bei 0.39 CHF/kWh",
|
||||
SolarCoverage: "Energieunabhängigkeit",
|
||||
FromSolar: "aus eigenem Solar + Batterie System",
|
||||
SolarCoverage: "Eigenversorgung",
|
||||
FromSolar: "aus Solar + Batterie",
|
||||
BatteryEff: "Batterie-Eff.",
|
||||
OutVsIn: "Entladung vs. Ladung",
|
||||
Day: "Tag",
|
||||
|
|
@ -114,8 +113,7 @@ public static class ReportEmailService
|
|||
GridIn: "Netz Ein",
|
||||
GridOut: "Netz Aus",
|
||||
BattInOut: "Batt. Laden/Entl.",
|
||||
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
FooterLink: "Detaillierte Berichte ansehen auf monitor.inesco.energy"
|
||||
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||||
),
|
||||
"fr" => new EmailStrings(
|
||||
Title: "Rapport de performance hebdomadaire",
|
||||
|
|
@ -136,8 +134,8 @@ public static class ReportEmailService
|
|||
StayedAtHome: "solaire + batterie, non achetée au réseau",
|
||||
EstMoneySaved: "Économies estimées",
|
||||
AtRate: "à 0.39 CHF/kWh",
|
||||
SolarCoverage: "Indépendance énergétique",
|
||||
FromSolar: "de votre système solaire + batterie",
|
||||
SolarCoverage: "Autosuffisance",
|
||||
FromSolar: "du solaire + batterie",
|
||||
BatteryEff: "Eff. batterie",
|
||||
OutVsIn: "décharge vs charge",
|
||||
Day: "Jour",
|
||||
|
|
@ -145,8 +143,7 @@ public static class ReportEmailService
|
|||
GridIn: "Réseau Ent.",
|
||||
GridOut: "Réseau Sor.",
|
||||
BattInOut: "Batt. Ch./Déch.",
|
||||
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
FooterLink: "Consultez vos rapports détaillés sur monitor.inesco.energy"
|
||||
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||||
),
|
||||
"it" => new EmailStrings(
|
||||
Title: "Rapporto settimanale delle prestazioni",
|
||||
|
|
@ -167,8 +164,8 @@ public static class ReportEmailService
|
|||
StayedAtHome: "solare + batteria, non acquistata dalla rete",
|
||||
EstMoneySaved: "Risparmio stimato",
|
||||
AtRate: "a 0.39 CHF/kWh",
|
||||
SolarCoverage: "Indipendenza energetica",
|
||||
FromSolar: "dal proprio impianto solare + batteria",
|
||||
SolarCoverage: "Autosufficienza",
|
||||
FromSolar: "da solare + batteria",
|
||||
BatteryEff: "Eff. batteria",
|
||||
OutVsIn: "scarica vs carica",
|
||||
Day: "Giorno",
|
||||
|
|
@ -176,8 +173,7 @@ public static class ReportEmailService
|
|||
GridIn: "Rete Ent.",
|
||||
GridOut: "Rete Usc.",
|
||||
BattInOut: "Batt. Car./Sc.",
|
||||
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
FooterLink: "Visualizza i tuoi report dettagliati su monitor.inesco.energy"
|
||||
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||||
),
|
||||
_ => new EmailStrings(
|
||||
Title: "Weekly Performance Report",
|
||||
|
|
@ -198,8 +194,8 @@ public static class ReportEmailService
|
|||
StayedAtHome: "solar + battery, not bought from grid",
|
||||
EstMoneySaved: "Est. Money Saved",
|
||||
AtRate: "at 0.39 CHF/kWh",
|
||||
SolarCoverage: "Energy Independence",
|
||||
FromSolar: "from your own solar + battery system",
|
||||
SolarCoverage: "Self-Sufficiency",
|
||||
FromSolar: "from solar + battery",
|
||||
BatteryEff: "Battery Eff.",
|
||||
OutVsIn: "discharge vs charge",
|
||||
Day: "Day",
|
||||
|
|
@ -207,17 +203,16 @@ public static class ReportEmailService
|
|||
GridIn: "Grid In",
|
||||
GridOut: "Grid Out",
|
||||
BattInOut: "Batt. Ch./Dis.",
|
||||
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
FooterLink: "View your detailed reports at monitor.inesco.energy"
|
||||
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||||
)
|
||||
};
|
||||
|
||||
// ── HTML email template ─────────────────────────────────────────────
|
||||
|
||||
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en", string customerName = null)
|
||||
=> BuildHtmlEmail(r, GetStrings(language), customerName);
|
||||
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en")
|
||||
=> BuildHtmlEmail(r, GetStrings(language));
|
||||
|
||||
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s, string customerName = null)
|
||||
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s)
|
||||
{
|
||||
var cur = r.CurrentWeek;
|
||||
var prev = r.PreviousWeek;
|
||||
|
|
@ -401,7 +396,6 @@ public static class ReportEmailService
|
|||
<tr>
|
||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||
{s.Footer}
|
||||
<div style=""margin-top:10px""><a href=""https://monitor.inesco.energy"" style=""color:#999;text-decoration:underline"">{s.FooterLink}</a></div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
|
@ -461,8 +455,7 @@ public static class ReportEmailService
|
|||
MonthlyReportSummary report,
|
||||
string installationName,
|
||||
string recipientEmail,
|
||||
string language = "en",
|
||||
string customerName = null)
|
||||
string language = "en")
|
||||
{
|
||||
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();
|
||||
|
|
@ -472,7 +465,7 @@ public static class ReportEmailService
|
|||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||
$"{report.WeekCount} {s.CountLabel}", s, customerName);
|
||||
$"{report.WeekCount} {s.CountLabel}", s);
|
||||
|
||||
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||
}
|
||||
|
|
@ -481,8 +474,7 @@ public static class ReportEmailService
|
|||
YearlyReportSummary report,
|
||||
string installationName,
|
||||
string recipientEmail,
|
||||
string language = "en",
|
||||
string customerName = null)
|
||||
string language = "en")
|
||||
{
|
||||
var s = GetAggregatedStrings(language, "yearly");
|
||||
var subject = $"{s.Title} — {installationName} ({report.Year})";
|
||||
|
|
@ -490,7 +482,7 @@ public static class ReportEmailService
|
|||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||
$"{report.MonthCount} {s.CountLabel}", s, customerName);
|
||||
$"{report.MonthCount} {s.CountLabel}", s);
|
||||
|
||||
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||
}
|
||||
|
|
@ -526,8 +518,7 @@ public static class ReportEmailService
|
|||
string GridImport, string GridExport, string BatteryInOut,
|
||||
string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved,
|
||||
string AtRate, string SolarCoverage, string FromSolar,
|
||||
string BatteryEff, string OutVsIn, string CountLabel, string Footer,
|
||||
string FooterLink
|
||||
string BatteryEff, string OutVsIn, string CountLabel, string Footer
|
||||
);
|
||||
|
||||
private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch
|
||||
|
|
@ -536,58 +527,50 @@ public static class ReportEmailService
|
|||
"Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat",
|
||||
"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",
|
||||
"Energieunabhängigkeit", "aus eigenem Solar + Batterie System", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||
"Tage aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
"Detaillierte Berichte ansehen auf monitor.inesco.energy"),
|
||||
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||
"Tage aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("de", "yearly") => new AggregatedEmailStrings(
|
||||
"Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr",
|
||||
"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",
|
||||
"Energieunabhängigkeit", "aus eigenem Solar + Batterie System", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
"Detaillierte Berichte ansehen auf monitor.inesco.energy"),
|
||||
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("fr", "monthly") => new AggregatedEmailStrings(
|
||||
"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",
|
||||
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||
"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>",
|
||||
"Consultez vos rapports détaillés sur monitor.inesco.energy"),
|
||||
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||
"jours agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("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",
|
||||
"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",
|
||||
"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>",
|
||||
"Consultez vos rapports détaillés sur monitor.inesco.energy"),
|
||||
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("it", "monthly") => new AggregatedEmailStrings(
|
||||
"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",
|
||||
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||
"Indipendenza energetica", "dal proprio impianto solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||
"giorni aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
"Visualizza i tuoi report dettagliati su monitor.inesco.energy"),
|
||||
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||
"giorni aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("it", "yearly") => new AggregatedEmailStrings(
|
||||
"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",
|
||||
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||
"Indipendenza energetica", "dal proprio impianto solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
"Visualizza i tuoi report dettagliati su monitor.inesco.energy"),
|
||||
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
(_, "monthly") => new AggregatedEmailStrings(
|
||||
"Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month",
|
||||
"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 Independence", "from your own solar + battery system", "Battery Eff.", "discharge vs charge",
|
||||
"days aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
"View your detailed reports at monitor.inesco.energy"),
|
||||
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
||||
"days aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
_ => new AggregatedEmailStrings(
|
||||
"Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year",
|
||||
"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 Independence", "from your own solar + battery system", "Battery Eff.", "discharge vs charge",
|
||||
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
|
||||
"View your detailed reports at monitor.inesco.energy")
|
||||
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
||||
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>")
|
||||
};
|
||||
|
||||
// ── Aggregated HTML email template ────────────────────────────────────
|
||||
|
|
@ -597,7 +580,7 @@ public static class ReportEmailService
|
|||
double pvProduction, double consumption, double gridImport, double gridExport,
|
||||
double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF,
|
||||
double selfSufficiency, double batteryEfficiency, string aiInsight,
|
||||
string countLabel, AggregatedEmailStrings s, string customerName = null)
|
||||
string countLabel, AggregatedEmailStrings s)
|
||||
{
|
||||
var insightLines = aiInsight
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
|
|
@ -677,7 +660,6 @@ public static class ReportEmailService
|
|||
<tr>
|
||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||
{s.Footer}
|
||||
<div style=""margin-top:10px""><a href=""https://monitor.inesco.energy"" style=""color:#999;text-decoration:underline"">{s.FooterLink}</a></div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
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,4 +1,3 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using Flurl.Http;
|
||||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
|
|
@ -10,54 +9,6 @@ public static class WeeklyReportService
|
|||
{
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -124,13 +75,14 @@ public static class WeeklyReportService
|
|||
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
||||
if (currentWeekDays.Count == 0)
|
||||
{
|
||||
// Only parse xlsx files whose date range overlaps the needed weeks
|
||||
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
|
||||
var xlsxFiles = Directory.Exists(TmpReportDir)
|
||||
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
|
||||
: new List<String>();
|
||||
|
||||
if (relevantFiles.Count > 0)
|
||||
if (xlsxFiles.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
||||
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
||||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx.");
|
||||
var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
||||
currentWeekDays = allDaysParsed
|
||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
||||
.ToList();
|
||||
|
|
@ -149,32 +101,9 @@ public static class WeeklyReportService
|
|||
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
||||
.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(
|
||||
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
|
||||
curMon, curSun, location, country, region);
|
||||
curMon, curSun);
|
||||
}
|
||||
|
||||
// ── Conversion helpers ─────────────────────────────────────────────
|
||||
|
|
@ -215,10 +144,7 @@ public static class WeeklyReportService
|
|||
string installationName,
|
||||
string language = "en",
|
||||
DateOnly? weekStart = null,
|
||||
DateOnly? weekEnd = null,
|
||||
string? location = null,
|
||||
string? country = null,
|
||||
string? region = null)
|
||||
DateOnly? weekEnd = null)
|
||||
{
|
||||
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
|
||||
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
|
||||
|
|
@ -262,7 +188,7 @@ public static class WeeklyReportService
|
|||
var aiInsight = await GetAiInsightAsync(
|
||||
currentWeekDays, currentSummary, previousSummary,
|
||||
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
||||
behavior, installationName, language, location, country, region);
|
||||
behavior, installationName, language);
|
||||
|
||||
return new WeeklyReportResponse
|
||||
{
|
||||
|
|
@ -327,10 +253,7 @@ public static class WeeklyReportService
|
|||
double totalSavingsCHF,
|
||||
BehavioralPattern behavior,
|
||||
string installationName,
|
||||
string language = "en",
|
||||
string? location = null,
|
||||
string? country = null,
|
||||
string? region = null)
|
||||
string language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
|
|
@ -339,12 +262,6 @@ public static class WeeklyReportService
|
|||
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;
|
||||
|
||||
// Detect which components are present
|
||||
|
|
@ -361,10 +278,7 @@ public static class WeeklyReportService
|
|||
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
|
||||
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
|
||||
|
||||
// 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)
|
||||
// Behavioral facts as compact lines
|
||||
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "–" + FormatHour(behavior.PeakSolarEndHour);
|
||||
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
|
||||
|
||||
|
|
@ -389,27 +303,15 @@ public static class WeeklyReportService
|
|||
? $"- 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
|
||||
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
|
||||
|
||||
var gridBehaviorLine = hasGrid
|
||||
? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour"
|
||||
: "";
|
||||
var gridBehaviorLine = hasGrid
|
||||
? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour"
|
||||
: "";
|
||||
|
||||
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}";
|
||||
}
|
||||
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
|
||||
|
||||
// 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.";
|
||||
|
|
@ -417,55 +319,22 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
var instruction2 = hasPv
|
||||
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
|
||||
: hasGrid
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})."
|
||||
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
|
||||
|
||||
var instruction3 = hasBattery
|
||||
? $"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.";
|
||||
|
||||
// Instruction 4 — adapts based on whether we have behavioral data
|
||||
string instruction4;
|
||||
if (hasBehavior && hasPv)
|
||||
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
|
||||
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 instruction4 = hasPv
|
||||
? $"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."
|
||||
: hasGrid
|
||||
? $"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."
|
||||
: "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern.";
|
||||
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||
|
||||
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
|
||||
|
||||
|
|
@ -478,16 +347,20 @@ DAILY FACTS:
|
|||
{pvDailyFact}
|
||||
{battDailyFact}
|
||||
{gridDailyFact}
|
||||
{behavioralSection}
|
||||
{weatherBlock}
|
||||
|
||||
BEHAVIORAL PATTERN (from hourly data this week):
|
||||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||||
- {weekdayWeekendLine}{pvBehaviorLines}
|
||||
{gridBehaviorLine}
|
||||
{battBehaviorLine}
|
||||
|
||||
INSTRUCTIONS:
|
||||
{instruction1}
|
||||
{instruction2}
|
||||
{instruction3}
|
||||
{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 {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.
|
||||
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.
|
||||
IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
||||
|
||||
try
|
||||
|
|
@ -496,7 +369,7 @@ IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
|||
{
|
||||
model = "mistral-small-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 600,
|
||||
max_tokens = 400,
|
||||
temperature = 0.3
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ public static class RabbitMqManager
|
|||
Console.WriteLine("RECEIVED A HEARTBIT FROM prototype, time is "+ WebsocketManager.InstallationConnections[installationId].Timestamp);
|
||||
}
|
||||
|
||||
Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status);
|
||||
installation.Status = receivedStatusMessage.Status;
|
||||
installation.Apply(Db.Update);
|
||||
|
||||
//Console.WriteLine("----------------------------------------------");
|
||||
//If the status has changed, update all the connected front-ends regarding this installation
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ public static class WebsocketManager
|
|||
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
||||
|
||||
installationConnection.Value.Status = (int)StatusType.Offline;
|
||||
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
||||
Installation installation = Db.Installations.FirstOrDefault(f => f.Product == installationConnection.Value.Product && f.Id == installationConnection.Key);
|
||||
installation.Status = (int)StatusType.Offline;
|
||||
installation.Apply(Db.Update);
|
||||
if (installationConnection.Value.Connections.Count > 0)
|
||||
{
|
||||
idsToInform.Add(installationConnection.Key);
|
||||
|
|
@ -59,31 +61,17 @@ public static class WebsocketManager
|
|||
public static async Task InformWebsocketsForInstallation(Int64 installationId)
|
||||
{
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null) return;
|
||||
|
||||
byte[] dataToSend;
|
||||
List<WebSocket> connections;
|
||||
|
||||
lock (InstallationConnections)
|
||||
{
|
||||
if (!InstallationConnections.ContainsKey(installationId))
|
||||
{
|
||||
Console.WriteLine($"InformWebsocketsForInstallation: No entry for installation {installationId}, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var installationConnection = InstallationConnections[installationId];
|
||||
|
||||
// Prune dead/closed connections BEFORE checking count
|
||||
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);
|
||||
|
||||
// Prune dead/closed connections before sending
|
||||
installationConnection.Connections.RemoveAll(c => c.State != WebSocketState.Open);
|
||||
|
||||
var jsonObject = new
|
||||
{
|
||||
id = installationId,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@
|
|||
"react-icons": "^4.11.0",
|
||||
"react-icons-converter": "^1.1.4",
|
||||
"react-intl": "^6.4.4",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router": "6.3.0",
|
||||
"react-router-dom": "6.3.0",
|
||||
|
|
@ -2877,11 +2876,6 @@
|
|||
"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": {
|
||||
"version": "0.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
|
||||
|
|
@ -8201,12 +8195,6 @@
|
|||
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -8217,6 +8205,7 @@
|
|||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -11476,11 +11465,6 @@
|
|||
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||
|
|
@ -15430,16 +15414,6 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
|
|
@ -17299,41 +17273,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"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": {
|
||||
"version": "10.3.17",
|
||||
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
|
||||
|
|
@ -17453,15 +17392,6 @@
|
|||
"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": {
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
|
||||
|
|
@ -17493,44 +17423,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"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": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
||||
|
|
@ -18371,16 +18263,6 @@
|
|||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
|
|
@ -19889,15 +19771,6 @@
|
|||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
|
||||
|
|
@ -23301,11 +23174,6 @@
|
|||
"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": {
|
||||
"version": "0.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
|
||||
|
|
@ -27187,11 +27055,6 @@
|
|||
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -27201,7 +27064,8 @@
|
|||
"deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"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": {
|
||||
"version": "6.0.3",
|
||||
|
|
@ -29594,11 +29458,6 @@
|
|||
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||
|
|
@ -32587,11 +32446,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
|
|
@ -33773,39 +33627,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"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": {
|
||||
"version": "10.3.17",
|
||||
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
|
||||
|
|
@ -33904,12 +33725,6 @@
|
|||
"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": {
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
|
||||
|
|
@ -33932,36 +33747,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"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": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
||||
|
|
@ -34549,16 +34334,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
|
|
@ -35745,15 +35520,6 @@
|
|||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@
|
|||
"react-icons": "^4.11.0",
|
||||
"react-icons-converter": "^1.1.4",
|
||||
"react-intl": "^6.4.4",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router": "6.3.0",
|
||||
"react-router-dom": "6.3.0",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import AccessContextProvider from './contexts/AccessContextProvider';
|
|||
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
|
||||
import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations';
|
||||
import { ProductIdContext } from './contexts/ProductIdContextProvider';
|
||||
import { TourProvider } from './contexts/TourContext';
|
||||
|
||||
function App() {
|
||||
const context = useContext(UserContext);
|
||||
|
|
@ -128,28 +127,22 @@ function App() {
|
|||
if (!token) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<IntlProvider
|
||||
messages={getTranslations()}
|
||||
locale={language}
|
||||
defaultLocale="en"
|
||||
>
|
||||
<CssBaseline />
|
||||
<Routes>
|
||||
<Route
|
||||
path={''}
|
||||
element={<Navigate to={routes.login}></Navigate>}
|
||||
></Route>
|
||||
<Route path={routes.login} element={<Login></Login>}></Route>
|
||||
<Route
|
||||
path={routes.forgotPassword}
|
||||
element={<ForgotPassword />}
|
||||
></Route>
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.login}></Navigate>}
|
||||
></Route>
|
||||
</Routes>
|
||||
</IntlProvider>
|
||||
<CssBaseline />
|
||||
<Routes>
|
||||
<Route
|
||||
path={''}
|
||||
element={<Navigate to={routes.login}></Navigate>}
|
||||
></Route>
|
||||
<Route path={routes.login} element={<Login></Login>}></Route>
|
||||
<Route
|
||||
path={routes.forgotPassword}
|
||||
element={<ForgotPassword />}
|
||||
></Route>
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.login}></Navigate>}
|
||||
></Route>
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -170,7 +163,6 @@ function App() {
|
|||
locale={language}
|
||||
defaultLocale="en"
|
||||
>
|
||||
<TourProvider>
|
||||
<InstallationsContextProvider>
|
||||
<CssBaseline />
|
||||
<Routes>
|
||||
|
|
@ -245,7 +237,6 @@ function App() {
|
|||
</Route>
|
||||
</Routes>
|
||||
</InstallationsContextProvider>
|
||||
</TourProvider>
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const axiosConfig = axios.create({
|
|||
axiosConfig.defaults.params = {};
|
||||
axiosConfig.interceptors.request.use(
|
||||
(config) => {
|
||||
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||
const tokenString = localStorage.getItem('token');
|
||||
const token = tokenString !== null ? tokenString : '';
|
||||
if (token) {
|
||||
config.params['authToken'] = token;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
|||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import routes from 'src/Resources/routes.json';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
interface ForgotPasswordPromps {
|
||||
resetPassword: () => void;
|
||||
|
|
@ -30,7 +29,6 @@ function ForgotPassword() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [errorModalOpen, setErrorModalOpen] = useState(false);
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const context = useContext(UserContext);
|
||||
|
||||
|
|
@ -107,7 +105,7 @@ function ForgotPassword() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
<FormattedMessage id="provideYourUsername" defaultMessage="Provide your username" />
|
||||
Provide your username
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -120,7 +118,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'userName', defaultMessage: 'User Name' })}
|
||||
label="User Name"
|
||||
variant="outlined"
|
||||
type="username"
|
||||
value={username}
|
||||
|
|
@ -152,7 +150,7 @@ function ForgotPassword() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
Submit
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
|
|
@ -178,7 +176,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<FormattedMessage id="usernameWrong" defaultMessage="Username is wrong. Please try again." />
|
||||
Username is wrong. Please try again.
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -190,7 +188,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
onClick={() => setErrorModalOpen(false)}
|
||||
>
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
@ -218,7 +216,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<FormattedMessage id="mailSentSuccessfully" defaultMessage="Mail sent successfully." />
|
||||
Mail sent successfully.
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -230,7 +228,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
onClick={handleReturn}
|
||||
>
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ import { TokenContext } from 'src/contexts/tokenContext';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
function ResetPassword() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const context = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -103,7 +102,7 @@ function ResetPassword() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
<FormattedMessage id="resetPassword" defaultMessage="Reset Password" />
|
||||
Reset Password
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -116,7 +115,7 @@ function ResetPassword() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||
label="Password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password}
|
||||
|
|
@ -127,7 +126,7 @@ function ResetPassword() {
|
|||
sx={{ width: 350 }}
|
||||
/>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
|
||||
label="Verify Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
value={verifypassword}
|
||||
|
|
@ -148,7 +147,7 @@ function ResetPassword() {
|
|||
variant="h5"
|
||||
sx={{ color: '#FF0000', marginTop: 1 }}
|
||||
>
|
||||
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
|
||||
Passwords do not match
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
|
@ -165,7 +164,7 @@ function ResetPassword() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
Submit
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
|
|
@ -191,7 +190,7 @@ function ResetPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<FormattedMessage id="resetPasswordFailed" defaultMessage="Reset Password failed. Please try again." />
|
||||
Reset Password failed. Please try again.
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -203,7 +202,7 @@ function ResetPassword() {
|
|||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ import { TokenContext } from 'src/contexts/tokenContext';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
function SetNewPassword() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const context = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -104,7 +103,7 @@ function SetNewPassword() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
<FormattedMessage id="setNewPassword" defaultMessage="Set New Password" />
|
||||
Set New Password
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -117,7 +116,7 @@ function SetNewPassword() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||
label="Password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password}
|
||||
|
|
@ -128,7 +127,7 @@ function SetNewPassword() {
|
|||
sx={{ width: 350 }}
|
||||
/>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
|
||||
label="Verify Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
value={verifypassword}
|
||||
|
|
@ -149,7 +148,7 @@ function SetNewPassword() {
|
|||
variant="h5"
|
||||
sx={{ color: '#FF0000', marginTop: 1 }}
|
||||
>
|
||||
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
|
||||
Passwords do not match
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
|
@ -166,7 +165,7 @@ function SetNewPassword() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
Submit
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
|
|
@ -192,7 +191,7 @@ function SetNewPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<FormattedMessage id="setNewPasswordFailed" defaultMessage="Setting new password failed. Please try again." />
|
||||
Setting new password failed. Please try again.
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -204,7 +203,7 @@ function SetNewPassword() {
|
|||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
|||
import Link from '@mui/material/Link';
|
||||
import inescologo from 'src/Resources/Logo.svg';
|
||||
import { axiosConfigWithoutToken } from 'src/Resources/axiosConfig';
|
||||
import Cookies from 'universal-cookie';
|
||||
import { UserContext } from 'src/contexts/userContext';
|
||||
import { TokenContext } from 'src/contexts/tokenContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
|
@ -24,7 +25,6 @@ import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
|||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import routes from 'src/Resources/routes.json';
|
||||
import { ProductIdContext } from '../contexts/ProductIdContextProvider';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
|
|
@ -34,7 +34,6 @@ function Login() {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const context = useContext(UserContext);
|
||||
const {
|
||||
|
|
@ -53,6 +52,7 @@ function Login() {
|
|||
const { currentUser, setUser, removeUser } = context;
|
||||
const tokencontext = useContext(TokenContext);
|
||||
const { token, setNewToken, removeToken } = tokencontext;
|
||||
const cookies = new Cookies();
|
||||
|
||||
const handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUsername(event.target.value);
|
||||
|
|
@ -78,14 +78,19 @@ function Login() {
|
|||
if (response.data && response.data.token) {
|
||||
setLoading(false);
|
||||
|
||||
setNewToken(response.data.token, rememberMe);
|
||||
setUser(response.data.user, rememberMe);
|
||||
setNewToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
|
||||
setAccessToSalimax(response.data.accessToSalimax);
|
||||
setAccessToSalidomo(response.data.accessToSalidomo);
|
||||
setAccessToSodiohome(response.data.accessToSodioHome);
|
||||
setAccessToSodistore(response.data.accessToSodistoreMax);
|
||||
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
||||
|
||||
if (rememberMe) {
|
||||
cookies.set('rememberedUsername', username, { path: '/' });
|
||||
cookies.set('rememberedPassword', password, { path: '/' });
|
||||
}
|
||||
if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else if (response.data.accessToSalidomo) {
|
||||
|
|
@ -142,7 +147,7 @@ function Login() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
<FormattedMessage id="signIn" defaultMessage="Sign in" />
|
||||
Sign in
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -155,7 +160,7 @@ function Login() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'username', defaultMessage: 'Username' })}
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={handleUsernameChange}
|
||||
fullWidth
|
||||
|
|
@ -171,7 +176,7 @@ function Login() {
|
|||
/>
|
||||
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||
label="Password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password}
|
||||
|
|
@ -198,7 +203,7 @@ function Login() {
|
|||
style={{ marginLeft: -175 }}
|
||||
/>
|
||||
}
|
||||
label={<FormattedMessage id="rememberMe" defaultMessage="Remember me" />}
|
||||
label="Remember me"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
@ -213,7 +218,7 @@ function Login() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<FormattedMessage id="login" defaultMessage="Login" />
|
||||
Login
|
||||
</Button>
|
||||
|
||||
{loading && (
|
||||
|
|
@ -248,7 +253,7 @@ function Login() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<FormattedMessage id="loginFailed" defaultMessage="Login failed. Please try again." />
|
||||
Login failed. Please try again.
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -260,7 +265,7 @@ function Login() {
|
|||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
@ -276,7 +281,7 @@ function Login() {
|
|||
onForgotPassword();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="forgotPasswordLink" defaultMessage="Forgot password?" />
|
||||
Forgot password?
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
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' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
Unable to communicate with the installation
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -111,10 +111,10 @@ function BatteryView(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||
Battery service is not available at the moment
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -229,24 +229,24 @@ function BatteryView(props: BatteryViewProps) {
|
|||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="firmware" defaultMessage="Firmware" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="temperature" defaultMessage="Temperature" /></TableCell>
|
||||
<TableCell align="center">Battery</TableCell>
|
||||
<TableCell align="center">Firmware</TableCell>
|
||||
<TableCell align="center">Power</TableCell>
|
||||
<TableCell align="center">Battery Voltage</TableCell>
|
||||
<TableCell align="center">SoC</TableCell>
|
||||
<TableCell align="center">Temperature</TableCell>
|
||||
{product === 0 ? (
|
||||
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
|
||||
<TableCell align="center">Warnings</TableCell>
|
||||
) : (
|
||||
<TableCell align="center"><FormattedMessage id="minCellVoltage" defaultMessage="Min Cell Voltage" /></TableCell>
|
||||
<TableCell align="center">Min Cell Voltage</TableCell>
|
||||
)}
|
||||
{product === 0 ? (
|
||||
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
|
||||
<TableCell align="center">Alarms</TableCell>
|
||||
) : (
|
||||
<TableCell align="center"><FormattedMessage id="maxCellVoltage" defaultMessage="Max Cell Voltage" /></TableCell>
|
||||
<TableCell align="center">Max Cell Voltage</TableCell>
|
||||
)}
|
||||
{(product === 3 || product === 4) && (
|
||||
<TableCell align="center"><FormattedMessage id="voltageDifference" defaultMessage="Voltage Difference" /></TableCell>
|
||||
<TableCell align="center">Voltage Difference</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
|
|
|||
|
|
@ -85,10 +85,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
Unable to communicate with the installation
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -109,10 +109,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||
Battery service is not available at the moment
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -209,14 +209,14 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
|||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="firmware" defaultMessage="Firmware" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="voltage" defaultMessage="Voltage" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="temperature" defaultMessage="Temperature" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
|
||||
<TableCell align="center">Battery</TableCell>
|
||||
<TableCell align="center">Firmware</TableCell>
|
||||
<TableCell align="center">Power</TableCell>
|
||||
<TableCell align="center">Voltage</TableCell>
|
||||
<TableCell align="center">SoC</TableCell>
|
||||
<TableCell align="center">Temperature</TableCell>
|
||||
<TableCell align="center">Warnings</TableCell>
|
||||
<TableCell align="center">Alarms</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -38,41 +38,20 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
const navigate = useNavigate();
|
||||
const inverter = (props.values as any)?.InverterRecord;
|
||||
const batteryClusterNumber = props.installation.batteryClusterNumber;
|
||||
|
||||
const hasDevices = !!inverter?.Devices;
|
||||
|
||||
const sortedBatteryView = inverter
|
||||
? Array.from({ length: batteryClusterNumber }, (_, i) => {
|
||||
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];
|
||||
const index = i + 1; // Battery1, Battery2, ...
|
||||
|
||||
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 {
|
||||
BatteryId: String(index),
|
||||
battery: {
|
||||
Voltage: inverter[`Battery${index}Voltage`] ?? 0,
|
||||
Current: inverter[`Battery${index}Current`] ?? 0,
|
||||
Power: inverter[`Battery${index}Power`] ?? 0,
|
||||
Soc: inverter[`Battery${index}Soc`] ?? 0,
|
||||
Soh: inverter[`Battery${index}Soh`] ?? 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
BatteryId: String(index),
|
||||
battery: {
|
||||
Voltage: inverter[`Battery${index}Voltage`],
|
||||
Current: inverter[`Battery${index}Current`],
|
||||
Power: inverter[`Battery${index}Power`],
|
||||
Soc: inverter[`Battery${index}Soc`],
|
||||
Soh: inverter[`Battery${index}Soh`],
|
||||
}
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
|
|
@ -108,10 +87,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
Unable to communicate with the installation
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -132,10 +111,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||
Battery service is not available at the moment
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -216,12 +195,12 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>
|
||||
<TableCell align="center">Battery</TableCell>
|
||||
<TableCell align="center">Power</TableCell>
|
||||
<TableCell align="center">Battery Voltage</TableCell>
|
||||
<TableCell align="center">Current</TableCell>
|
||||
<TableCell align="center">SoC</TableCell>
|
||||
<TableCell align="center">SoH</TableCell>
|
||||
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
|
||||
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -634,66 +634,66 @@ function DetailedBatteryViewSodistore(
|
|||
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
||||
{/*Temperature List*/}
|
||||
|
||||
{/*<Card*/}
|
||||
{/* sx={{*/}
|
||||
{/* overflow: 'visible',*/}
|
||||
{/* marginTop: '20px',*/}
|
||||
{/* marginLeft: '20px',*/}
|
||||
{/* display: 'flex',*/}
|
||||
{/* flexDirection: 'column',*/}
|
||||
{/* alignItems: 'center',*/}
|
||||
{/* border: '2px solid #ccc',*/}
|
||||
{/* borderRadius: '12px',*/}
|
||||
{/* height: '270px'*/}
|
||||
{/* }}*/}
|
||||
{/*>*/}
|
||||
{/* <Typography*/}
|
||||
{/* variant="h6"*/}
|
||||
{/* component="div"*/}
|
||||
{/* sx={{*/}
|
||||
{/* marginTop: '10px',*/}
|
||||
{/* borderBottom: '1px solid #ccc',*/}
|
||||
{/* fontWeight: 'bold'*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* Battery Temperatures*/}
|
||||
{/* </Typography>*/}
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
marginTop: '20px',
|
||||
marginLeft: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
border: '2px solid #ccc',
|
||||
borderRadius: '12px',
|
||||
height: '270px'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
sx={{
|
||||
marginTop: '10px',
|
||||
borderBottom: '1px solid #ccc',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Battery Temperatures
|
||||
</Typography>
|
||||
|
||||
{/* <TableContainer*/}
|
||||
{/* component={Paper}*/}
|
||||
{/* sx={{*/}
|
||||
{/* marginTop: '20px',*/}
|
||||
{/* width: '100%',*/}
|
||||
{/* maxHeight: '270px',*/}
|
||||
{/* overflowY: 'auto'*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* <Table size="medium" stickyHeader aria-label="temperature table">*/}
|
||||
{/* <TableBody>*/}
|
||||
{/* {Object.entries(*/}
|
||||
{/* props.batteryData.BatteryDeligreenDataRecord*/}
|
||||
{/* .TemperaturesList || {}*/}
|
||||
{/* ).map(([label, value]) => (*/}
|
||||
{/* <TableRow key={label}>*/}
|
||||
{/* <TableCell align="left" sx={{ fontWeight: 'bold' }}>*/}
|
||||
{/* {label}*/}
|
||||
{/* </TableCell>*/}
|
||||
{/* <TableCell*/}
|
||||
{/* align="right"*/}
|
||||
{/* sx={{*/}
|
||||
{/* width: '6ch',*/}
|
||||
{/* whiteSpace: 'nowrap',*/}
|
||||
{/* paddingRight: '12px'*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* {value + ' °C'}*/}
|
||||
{/* </TableCell>*/}
|
||||
{/* </TableRow>*/}
|
||||
{/* ))}*/}
|
||||
{/* </TableBody>*/}
|
||||
{/* </Table>*/}
|
||||
{/* </TableContainer>*/}
|
||||
{/*</Card>*/}
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{
|
||||
marginTop: '20px',
|
||||
width: '100%',
|
||||
maxHeight: '270px', // scrolling threshold
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<Table size="medium" stickyHeader aria-label="temperature table">
|
||||
<TableBody>
|
||||
{Object.entries(
|
||||
props.batteryData.BatteryDeligreenDataRecord
|
||||
.TemperaturesList || {}
|
||||
).map(([label, value]) => (
|
||||
<TableRow key={label}>
|
||||
<TableCell align="left" sx={{ fontWeight: 'bold' }}>
|
||||
{label}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
width: '6ch',
|
||||
whiteSpace: 'nowrap',
|
||||
paddingRight: '12px'
|
||||
}}
|
||||
>
|
||||
{value + ' °C'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ function MainStats(props: MainStatsProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
Fetching data...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ function MainStatsSalidomo(props: MainStatsProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
Fetching data...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
Fetching data...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from '@mui/material';
|
||||
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
|
@ -40,7 +40,6 @@ interface ConfigurationProps {
|
|||
}
|
||||
|
||||
function Configuration(props: ConfigurationProps) {
|
||||
const intl = useIntl();
|
||||
if (props.values === null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -138,7 +137,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
props.values.EssControl.Mode === 'CalibrationCharge'
|
||||
) {
|
||||
setDateSelectionError(
|
||||
intl.formatMessage({ id: 'cannotChangeDateCalibration' })
|
||||
'You cannot change the date while the installation is in Calibration Charge Mode'
|
||||
);
|
||||
setErrorDateModalOpen(true);
|
||||
return;
|
||||
|
|
@ -147,7 +146,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
dayjs(formValues.calibrationChargeDate).isBefore(dayjs())
|
||||
) {
|
||||
//console.log('asked for', dayjs(formValues.calibrationChargeDate));
|
||||
setDateSelectionError(intl.formatMessage({ id: 'mustSpecifyFutureDate' }));
|
||||
setDateSelectionError('You must specify a future date');
|
||||
setErrorDateModalOpen(true);
|
||||
return;
|
||||
} else {
|
||||
|
|
@ -459,7 +458,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
helperText={
|
||||
errors.minimumSoC ? (
|
||||
<span style={{ color: 'red' }}>
|
||||
{intl.formatMessage({ id: 'valueBetween0And100' })}
|
||||
Value should be between 0-100%
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
|
|
@ -593,7 +592,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
helperText={
|
||||
errors.gridSetPoint ? (
|
||||
<span style={{ color: 'red' }}>
|
||||
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
|
||||
Please provide a valid number
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
|
|
@ -805,7 +804,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
|
||||
Successfully applied configuration file
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
@ -825,7 +824,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
|
||||
An error has occurred
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ import timezone from 'dayjs/plugin/timezone';
|
|||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { UserContext } from '../../../contexts/userContext';
|
||||
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
|
@ -60,8 +58,6 @@ function HistoryOfActions(props: HistoryProps) {
|
|||
});
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser, setUser } = context;
|
||||
const { fetchAllInstallations } = useContext(InstallationsContext);
|
||||
const { product } = useContext(ProductIdContext);
|
||||
const [isRowHovered, setHoveredRow] = useState(-1);
|
||||
const [selectedAction, setSelectedAction] = useState<number>(-1);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
|
@ -113,7 +109,6 @@ function HistoryOfActions(props: HistoryProps) {
|
|||
|
||||
if (res) {
|
||||
getHistory();
|
||||
fetchAllInstallations(product, false);
|
||||
setOpenModalAddAction(false);
|
||||
setEditMode(false);
|
||||
}
|
||||
|
|
@ -134,7 +129,6 @@ function HistoryOfActions(props: HistoryProps) {
|
|||
|
||||
if (res) {
|
||||
getHistory();
|
||||
fetchAllInstallations(product, false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
|
|
@ -15,14 +14,13 @@ import {
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { I_S3Credentials } from '../../../interfaces/S3Types';
|
||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
||||
import { UserContext } from '../../../contexts/userContext';
|
||||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
|
|
@ -55,24 +53,6 @@ function Information(props: InformationProps) {
|
|||
deleteInstallation
|
||||
} = 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 { name, value } = e.target;
|
||||
setFormValues({
|
||||
|
|
@ -306,54 +286,6 @@ function Information(props: InformationProps) {
|
|||
error={formValues.country === ''}
|
||||
/>
|
||||
</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>
|
||||
<TextField
|
||||
label={
|
||||
|
|
@ -409,7 +341,7 @@ function Information(props: InformationProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<>
|
||||
<div>
|
||||
<TextField
|
||||
|
|
@ -468,7 +400,7 @@ function Information(props: InformationProps) {
|
|||
marginTop: 10
|
||||
}}
|
||||
>
|
||||
{canEdit && (
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDelete}
|
||||
|
|
@ -482,7 +414,7 @@ function Information(props: InformationProps) {
|
|||
/>
|
||||
</Button>
|
||||
)}
|
||||
{(canEdit || (isPartner && isSodistore)) && (
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
|
|
@ -27,7 +26,6 @@ import { UserContext } from '../../../contexts/userContext';
|
|||
import routes from '../../../Resources/routes.json';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
|
||||
interface InformationSodistorehomeProps {
|
||||
values: I_Installation;
|
||||
|
|
@ -180,18 +178,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
const canEdit = currentUser.userType === UserType.admin;
|
||||
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 (
|
||||
<>
|
||||
{openModalDeleteInstallation && (
|
||||
|
|
@ -375,52 +361,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
/>
|
||||
</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 && (
|
||||
<div>
|
||||
<TextField
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
|
|
@ -33,7 +33,6 @@ interface FlatInstallationViewProps {
|
|||
}
|
||||
|
||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||
const intl = useIntl();
|
||||
const [isRowHovered, setHoveredRow] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
|
|
@ -203,7 +202,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
@ -227,7 +226,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByStatus}
|
||||
onChange={(e) => setSortByStatus(e.target.value)}
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
label="Show Only"
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
@ -253,7 +252,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByAction}
|
||||
onChange={(e) => setSortByAction(e.target.value)}
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
label="Show Only"
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
|
|||
|
|
@ -200,7 +200,6 @@ function Installation(props: singleInstallationProps) {
|
|||
currentTab == 'live' ||
|
||||
currentTab == 'pvview' ||
|
||||
currentTab == 'configuration' ||
|
||||
currentTab == 'overview' ||
|
||||
location.includes('batteryview')
|
||||
) {
|
||||
//Fetch periodically if the tab is live, pvview or batteryview
|
||||
|
|
@ -218,10 +217,6 @@ function Installation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fetch one time in overview tab to determine connectivity
|
||||
if (currentTab == 'overview') {
|
||||
fetchDataForOneTime();
|
||||
}
|
||||
//Fetch only one time in configuration tab
|
||||
if (currentTab == 'configuration') {
|
||||
fetchDataForOneTime();
|
||||
|
|
@ -381,6 +376,7 @@ function Installation(props: singleInstallationProps) {
|
|||
currentTab != 'information' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'manage' &&
|
||||
currentTab != 'overview' &&
|
||||
currentTab != 'log' && (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
|
|
@ -398,7 +394,7 @@ function Installation(props: singleInstallationProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||
Connecting to the device...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -451,8 +447,6 @@ function Installation(props: singleInstallationProps) {
|
|||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></Overview>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,66 +5,6 @@ import { S3Access } from 'src/dataCache/S3/S3Access';
|
|||
import { JSONRecordData, parseChunkJson } from '../Log/graph.util';
|
||||
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 = (
|
||||
timestamp: UnixTime,
|
||||
s3Credentials?: I_S3Credentials,
|
||||
|
|
@ -110,8 +50,7 @@ export const fetchDataJson = (
|
|||
|
||||
export const fetchAggregatedDataJson = (
|
||||
date: string,
|
||||
s3Credentials?: I_S3Credentials,
|
||||
product?: number
|
||||
s3Credentials?: I_S3Credentials
|
||||
): Promise<FetchResult<any>> => {
|
||||
const s3Path = `${date}.json`;
|
||||
|
||||
|
|
@ -129,12 +68,7 @@ export const fetchAggregatedDataJson = (
|
|||
if (r.status === 404) {
|
||||
return Promise.resolve(FetchResult.notAvailable);
|
||||
} else if (r.status === 200) {
|
||||
const jsontext = await r.text();
|
||||
|
||||
if (product === 2) {
|
||||
return parseSinexcelAggregatedData(jsontext);
|
||||
}
|
||||
|
||||
const jsontext = await r.text(); // Assuming the server returns the Base64 encoded ZIP file as text
|
||||
const contentEncoding = r.headers.get('content-type');
|
||||
|
||||
if (contentEncoding != 'application/base64; charset=utf-8') {
|
||||
|
|
@ -148,6 +82,7 @@ export const fetchAggregatedDataJson = (
|
|||
const zip = await JSZip.loadAsync(byteArray);
|
||||
// Assuming the CSV file is named "data.csv" inside the ZIP archive
|
||||
const jsonContent = await zip.file('data.json').async('text');
|
||||
//console.log(jsonContent);
|
||||
return JSON.parse(jsonContent);
|
||||
} else {
|
||||
return Promise.resolve(FetchResult.notAvailable);
|
||||
|
|
@ -159,24 +94,6 @@ 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,7 +412,6 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
? routes[tab.value]
|
||||
: navigateToTabPath(location.pathname, routes[tab.value])
|
||||
}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
@ -481,7 +480,6 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
component={Link}
|
||||
label={tab.label}
|
||||
to={routes[tab.value]}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -259,8 +259,7 @@ function Log(props: LogProps) {
|
|||
<Container maxWidth="xl">
|
||||
<Grid container>
|
||||
|
||||
{/* ── AI Diagnosis Demo Panel (commented out — using live AI diagnosis only) ── */}
|
||||
{/*
|
||||
{/* ── AI Diagnosis Demo Panel ── */}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
|
|
@ -356,7 +355,6 @@ function Log(props: LogProps) {
|
|||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
*/}
|
||||
|
||||
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
|
||||
{diagnosisLoading && (
|
||||
|
|
|
|||
|
|
@ -437,76 +437,23 @@ export interface JSONRecordData {
|
|||
};
|
||||
};
|
||||
|
||||
// For SodistoreHome (Sinexcel multi-inverter structure)
|
||||
// For SodistoreHome
|
||||
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;
|
||||
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;
|
||||
Battery1Soc: number;
|
||||
Battery1Soh: number;
|
||||
Battery1Voltage: number;
|
||||
Battery1Current: number;
|
||||
Battery1PackTotalVoltage: number;
|
||||
Battery1PackTotalCurrent: number;
|
||||
Battery1Temperature: number;
|
||||
Battery1SocSecondvalue: number;
|
||||
Battery2Power: number;
|
||||
Battery2Soc: number;
|
||||
Battery2Soh: number;
|
||||
Battery2Voltage: number;
|
||||
Battery2Current: number;
|
||||
Battery2PackTotalVoltage: number;
|
||||
Battery2PackTotalCurrent: number;
|
||||
Battery2Temperature: number;
|
||||
Battery2Socsecondvalue: number;
|
||||
ConsumptionPower: number;
|
||||
TotalPhotovoltaicPower: number;
|
||||
TotalBatteryPower: number;
|
||||
TotalLoadPower: number;
|
||||
TotalGridPower: number;
|
||||
GridPower: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
[key: string]: any;
|
||||
GridPower:number;
|
||||
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;
|
||||
};
|
||||
|
||||
AcDcGrowatt: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
transformInputToDailyDataJson
|
||||
} from 'src/interfaces/Chart';
|
||||
import Button from '@mui/material/Button';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
|
|
@ -33,9 +33,6 @@ import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
|||
interface OverviewProps {
|
||||
s3Credentials: I_S3Credentials;
|
||||
id: number;
|
||||
device?: number;
|
||||
connected?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const computeLast7Days = (): string[] => {
|
||||
|
|
@ -55,7 +52,6 @@ const computeLast7Days = (): string[] => {
|
|||
};
|
||||
|
||||
function Overview(props: OverviewProps) {
|
||||
const intl = useIntl();
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
const [dailyData, setDailyData] = useState(true);
|
||||
|
|
@ -106,12 +102,6 @@ function Overview(props: OverviewProps) {
|
|||
}
|
||||
}, [isZooming, dailyDataArray]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.connected === false) {
|
||||
setErrorDateModalOpen(false);
|
||||
}
|
||||
}, [props.connected]);
|
||||
|
||||
useEffect(() => {
|
||||
const resultPromise: Promise<{
|
||||
chartData: chartDataInterface;
|
||||
|
|
@ -129,6 +119,8 @@ function Overview(props: OverviewProps) {
|
|||
resultPromise
|
||||
.then((result) => {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
|
@ -217,19 +209,11 @@ function Overview(props: OverviewProps) {
|
|||
}> = transformInputToAggregatedDataJson(
|
||||
props.s3Credentials,
|
||||
dayjs().subtract(1, 'week'),
|
||||
dayjs(),
|
||||
product
|
||||
dayjs()
|
||||
);
|
||||
|
||||
resultPromise
|
||||
.then((result) => {
|
||||
if (result.dateList.length === 0) {
|
||||
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||
setErrorDateModalOpen(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const powerDifference = [];
|
||||
for (
|
||||
let i = 0;
|
||||
|
|
@ -304,7 +288,7 @@ function Overview(props: OverviewProps) {
|
|||
resultPromise
|
||||
.then((result) => {
|
||||
if (result.chartData.soc.data.length === 0) {
|
||||
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
|
||||
setErrorDateModalOpen(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
|
|
@ -335,19 +319,11 @@ function Overview(props: OverviewProps) {
|
|||
}> = transformInputToAggregatedDataJson(
|
||||
props.s3Credentials,
|
||||
startDate,
|
||||
endDate,
|
||||
product
|
||||
endDate
|
||||
);
|
||||
|
||||
resultPromise
|
||||
.then((result) => {
|
||||
if (result.dateList.length === 0) {
|
||||
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||
setErrorDateModalOpen(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const powerDifference = [];
|
||||
|
||||
for (
|
||||
|
|
@ -403,23 +379,6 @@ function Overview(props: OverviewProps) {
|
|||
const renderGraphs = () => {
|
||||
return (
|
||||
<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 && (
|
||||
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
|
||||
<Box
|
||||
|
|
@ -550,7 +509,6 @@ function Overview(props: OverviewProps) {
|
|||
</LocalizationProvider>
|
||||
)}
|
||||
<Grid container>
|
||||
{!props.loading && (props.connected !== false || dailyDataArray.length > 0 || aggregatedDataArray.length > 0) && (<>
|
||||
<Grid item xs={6} md={6}>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -565,23 +523,24 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||
</Button>
|
||||
{props.device !== 3 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
marginTop: '20px',
|
||||
marginLeft: '10px',
|
||||
backgroundColor: aggregatedData ? '#808080' : '#ffc04d',
|
||||
color: '#000000',
|
||||
'&:hover': { bgcolor: '#f7b34d' }
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="lastweek" defaultMessage="Last week" />
|
||||
</Button>
|
||||
{product !== 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
marginTop: '20px',
|
||||
marginLeft: '10px',
|
||||
backgroundColor: aggregatedData ? '#808080' : '#ffc04d',
|
||||
color: '#000000',
|
||||
'&:hover': { bgcolor: '#f7b34d' }
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="lastweek" defaultMessage="Last week" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/*{aggregatedData && (*/}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSetDate}
|
||||
|
|
@ -596,6 +555,7 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="set_date" defaultMessage="Set Date" />
|
||||
</Button>
|
||||
{/*)}*/}
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
|
|
@ -642,7 +602,6 @@ function Overview(props: OverviewProps) {
|
|||
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</>)}
|
||||
|
||||
{loading && (
|
||||
<Container
|
||||
|
|
@ -657,7 +616,7 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
Fetching data...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -808,7 +767,7 @@ function Overview(props: OverviewProps) {
|
|||
{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.gridExportPower,
|
||||
color: '#2e7d32',
|
||||
color: '#ff3333',
|
||||
type: 'bar'
|
||||
},
|
||||
{
|
||||
|
|
@ -817,13 +776,13 @@ function Overview(props: OverviewProps) {
|
|||
type: 'bar',
|
||||
color: '#ff9900'
|
||||
},
|
||||
...(product !== 2 ? [{
|
||||
{
|
||||
name: 'Net Energy',
|
||||
color: '#e65100',
|
||||
color: '#ff3333',
|
||||
type: 'line',
|
||||
data: aggregatedDataArray[aggregatedChartState]
|
||||
.netbalance
|
||||
}] : [])
|
||||
}
|
||||
]}
|
||||
height={400}
|
||||
type={'bar'}
|
||||
|
|
@ -840,7 +799,6 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
{!(aggregatedData && product === 2) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -932,8 +890,7 @@ function Overview(props: OverviewProps) {
|
|||
)}
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={(aggregatedData && product === 2) ? 12 : 6} xs={12}>
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -1001,14 +958,11 @@ function Overview(props: OverviewProps) {
|
|||
<ReactApexChart
|
||||
options={{
|
||||
...getChartOptions(
|
||||
product === 2
|
||||
? aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPowerWithoutHeating
|
||||
: aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPower,
|
||||
aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPower,
|
||||
'weekly',
|
||||
aggregatedDataArray[aggregatedChartState].datelist,
|
||||
product === 2
|
||||
false
|
||||
)
|
||||
}}
|
||||
series={[
|
||||
|
|
@ -1017,11 +971,11 @@ function Overview(props: OverviewProps) {
|
|||
.chartData.dcChargingPower,
|
||||
color: '#008FFB'
|
||||
},
|
||||
...(product !== 2 ? [{
|
||||
{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.heatingPower,
|
||||
color: '#ff9900'
|
||||
}] : []),
|
||||
},
|
||||
{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.dcDischargingPower,
|
||||
|
|
@ -1073,8 +1027,7 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
{product !== 2 && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Grid item md={product === 2 ? 12 : 6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -1135,7 +1088,6 @@ function Overview(props: OverviewProps) {
|
|||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
{product !== 2 && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
|
|
@ -1392,63 +1344,6 @@ function Overview(props: OverviewProps) {
|
|||
</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 && (
|
||||
<Grid
|
||||
container
|
||||
|
|
@ -1590,10 +1485,6 @@ function Overview(props: OverviewProps) {
|
|||
);
|
||||
};
|
||||
|
||||
if (props.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{renderGraphs()}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ import { UserType } from '../../../interfaces/UserTypes';
|
|||
interface salidomoOverviewProps {
|
||||
s3Credentials: I_S3Credentials;
|
||||
id: number;
|
||||
connected?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const computeLast7Days = (): string[] => {
|
||||
|
|
@ -407,7 +405,7 @@ const computeLast7Days = (): string[] => {
|
|||
// >
|
||||
// <CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
// <Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
// <FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
// Fetching data...
|
||||
// </Typography>
|
||||
// </Container>
|
||||
// )}
|
||||
|
|
@ -752,23 +750,6 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
const renderGraphs = () => {
|
||||
return (
|
||||
<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 && (
|
||||
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
|
||||
<Box
|
||||
|
|
@ -893,7 +874,6 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
</LocalizationProvider>
|
||||
)}
|
||||
<Grid container>
|
||||
{!props.loading && (props.connected !== false || aggregatedDataArray.length > 0) && (<>
|
||||
<Grid item xs={6} md={6}>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -951,7 +931,6 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</>)}
|
||||
|
||||
{loading && (
|
||||
<Container
|
||||
|
|
@ -966,7 +945,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
Fetching data...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -1144,10 +1123,6 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
);
|
||||
};
|
||||
|
||||
if (props.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{renderGraphs()}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { JSONRecordData } from '../Log/graph.util';
|
|||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface PvViewProps {
|
||||
values: JSONRecordData;
|
||||
|
|
@ -81,10 +80,10 @@ function PvView(props: PvViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
Unable to communicate with the installation
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -106,10 +105,10 @@ function PvView(props: PvViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="pvViewNotAvailable" defaultMessage="Pv view is not available at the moment" />
|
||||
Pv view is not available at the moment
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -131,10 +130,10 @@ function PvView(props: PvViewProps) {
|
|||
<Table sx={{ minWidth: 250 }} aria-label={`CU${deviceId} table`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center"><FormattedMessage id="pv" defaultMessage="Pv" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="voltage" defaultMessage="Voltage" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
|
||||
<TableCell align="center">Pv</TableCell>
|
||||
<TableCell align="center">Power</TableCell>
|
||||
<TableCell align="center">Voltage</TableCell>
|
||||
<TableCell align="center">Current</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
useTheme
|
||||
} from '@mui/material';
|
||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
|
@ -32,7 +32,6 @@ interface FlatInstallationViewProps {
|
|||
}
|
||||
|
||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
const currentLocation = useLocation();
|
||||
|
|
@ -183,7 +182,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
@ -207,7 +206,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByStatus}
|
||||
onChange={(e) => setSortByStatus(e.target.value)}
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
label="Show Only"
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
@ -233,7 +232,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByAction}
|
||||
onChange={(e) => setSortByAction(e.target.value)}
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
label="Show Only"
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
|
|||
|
|
@ -157,31 +157,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
setCurrentTab(path[path.length - 1]);
|
||||
}, [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(() => {
|
||||
if (location.includes('batteryview') || currentTab == 'overview') {
|
||||
if (location.includes('batteryview')) {
|
||||
if (location.includes('batteryview') && !location.includes('mainstats')) {
|
||||
if (!continueFetching.current) {
|
||||
continueFetching.current = true;
|
||||
|
|
@ -191,10 +168,6 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fetch one time in overview tab to determine connectivity
|
||||
if (currentTab == 'overview') {
|
||||
fetchDataForOneTime();
|
||||
}
|
||||
|
||||
return () => {
|
||||
continueFetching.current = false;
|
||||
|
|
@ -322,6 +295,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'overview' &&
|
||||
currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' && (
|
||||
|
|
@ -341,7 +315,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||
Connecting to the device...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -383,8 +357,6 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
<SalidomoOverview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></SalidomoOverview>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -288,7 +288,6 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
? routes[tab.value]
|
||||
: navigateToTabPath(location.pathname, routes[tab.value])
|
||||
}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
@ -350,7 +349,6 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
component={Link}
|
||||
label={tab.label}
|
||||
to={routes[tab.value]}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,614 +0,0 @@
|
|||
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,52 +111,20 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
// Probe multiple timestamps in parallel, return first successful result
|
||||
const probeTimestampBatch = async (
|
||||
offsets: number[]
|
||||
): 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;
|
||||
});
|
||||
|
||||
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 () => {
|
||||
var timeperiodToSearch = 350;
|
||||
let res;
|
||||
let timestampToFetch: UnixTime;
|
||||
let timestampToFetch;
|
||||
|
||||
// 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) {
|
||||
for (var i = 0; i < timeperiodToSearch; i += 30) {
|
||||
if (!continueFetching.current) {
|
||||
return false;
|
||||
}
|
||||
const offsets = [];
|
||||
for (let j = 0; j < batchSize; j++) {
|
||||
const offset = batchStart + j * step;
|
||||
if (offset < maxAge) offsets.push(offset);
|
||||
}
|
||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
||||
|
||||
try {
|
||||
const hit = await probeTimestampBatch(offsets);
|
||||
if (hit) {
|
||||
res = hit.res;
|
||||
timestampToFetch = hit.timestamp;
|
||||
found = true;
|
||||
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
|
||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -165,7 +133,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
if (i >= timeperiodToSearch) {
|
||||
setConnected(false);
|
||||
setLoading(false);
|
||||
return false;
|
||||
|
|
@ -186,12 +154,10 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
await timeout(2000);
|
||||
}
|
||||
|
||||
// Advance by 150s to find the next chunk (15 records × 10s interval)
|
||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(150));
|
||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(60));
|
||||
console.log('NEW TIMESTAMP TO FETCH IS ' + timestampToFetch);
|
||||
|
||||
let foundNext = false;
|
||||
for (var i = 0; i < 60; i++) {
|
||||
for (i = 0; i < 30; i++) {
|
||||
if (!continueFetching.current) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -203,7 +169,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
res !== FetchResult.notAvailable &&
|
||||
res !== FetchResult.tryLater
|
||||
) {
|
||||
foundNext = true;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -212,30 +177,24 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(1));
|
||||
}
|
||||
if (!foundNext) {
|
||||
if (i == 30) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDataForOneTime = async () => {
|
||||
// Search backward in parallel batches of 10 timestamps (2s apart)
|
||||
const batchSize = 10;
|
||||
const step = 2;
|
||||
const maxAge = 400;
|
||||
var timeperiodToSearch = 300; // 5 minutes to cover ~2 upload cycles (150s each)
|
||||
let res;
|
||||
let timestampToFetch;
|
||||
|
||||
for (let batchStart = 0; batchStart < maxAge; batchStart += batchSize * step) {
|
||||
const offsets = [];
|
||||
for (let j = 0; j < batchSize; j++) {
|
||||
const offset = batchStart + j * step;
|
||||
if (offset < maxAge) offsets.push(offset);
|
||||
}
|
||||
|
||||
// Search from NOW backward to find the most recent data
|
||||
// Step by 50 seconds - data is uploaded every ~150s, so finer steps are wasteful
|
||||
for (var i = 0; i < timeperiodToSearch; i += 50) {
|
||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
||||
try {
|
||||
const hit = await probeTimestampBatch(offsets);
|
||||
if (hit) {
|
||||
res = hit.res;
|
||||
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
|
||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -244,12 +203,11 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
if (i >= timeperiodToSearch) {
|
||||
setConnected(false);
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setConnected(true);
|
||||
setLoading(false);
|
||||
|
||||
|
|
@ -257,6 +215,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
const timestamps = Object.keys(res).sort((a, b) => Number(b) - Number(a));
|
||||
const latestTimestamp = timestamps[0];
|
||||
setValues(res[latestTimestamp]);
|
||||
// setValues(
|
||||
// extractValues({
|
||||
// time: UnixTime.fromTicks(parseInt(timestamp, 10)),
|
||||
// value: res[timestamp]
|
||||
// })
|
||||
// );
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -276,7 +240,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
currentTab == 'live' ||
|
||||
currentTab == 'pvview' ||
|
||||
currentTab == 'configuration' ||
|
||||
currentTab == 'overview' ||
|
||||
location.includes('batteryview')
|
||||
) {
|
||||
//Fetch periodically if the tab is live, pvview or batteryview
|
||||
|
|
@ -294,20 +257,14 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// Fetch periodically in configuration tab (every 30 seconds to detect S3 updates)
|
||||
if (currentTab == 'configuration') {
|
||||
fetchDataForOneTime();
|
||||
fetchDataForOneTime(); // Initial fetch
|
||||
|
||||
const configRefreshInterval = setInterval(() => {
|
||||
console.log('Refreshing configuration data from S3...');
|
||||
fetchDataForOneTime();
|
||||
}, 30000);
|
||||
}, 60000); // Refresh every 60 seconds (data uploads every ~150s)
|
||||
|
||||
return () => {
|
||||
continueFetching.current = false;
|
||||
|
|
@ -383,10 +340,11 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{values.InverterRecord?.OperatingPriority
|
||||
?? values.InverterRecord?.WorkingMode
|
||||
?? values.InverterRecord?.OperatingMode
|
||||
?? values.Config?.OperatingPriority}
|
||||
{props.current_installation.device === 4
|
||||
? values.InverterRecord?.WorkingMode
|
||||
: props.current_installation.device === 3
|
||||
? values.InverterRecord?.OperatingMode
|
||||
: values.Config.OperatingPriority}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -470,6 +428,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'overview' &&
|
||||
currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' &&
|
||||
|
|
@ -490,7 +449,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||
Connecting to the device...
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -600,9 +559,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
|
||||
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
|
|
@ -13,7 +12,6 @@ interface installationSearchProps {
|
|||
}
|
||||
|
||||
function InstallationSearch(props: installationSearchProps) {
|
||||
const intl = useIntl();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const currentLocation = useLocation();
|
||||
// const [filteredData, setFilteredData] = useState(props.installations);
|
||||
|
|
@ -62,7 +60,7 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
>
|
||||
<FormControl variant="outlined">
|
||||
<TextField
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from '@mui/material';
|
||||
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
|
@ -39,7 +39,6 @@ interface SodistoreHomeConfigurationProps {
|
|||
}
|
||||
|
||||
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||
const intl = useIntl();
|
||||
if (props.values === null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -47,16 +46,19 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
const device = props.installation.device;
|
||||
|
||||
const OperatingPriorityOptions =
|
||||
device === 3 || device === 4
|
||||
device === 3 // Growatt
|
||||
? ['LoadPriority', 'BatteryPriority', 'GridPriority']
|
||||
: [];
|
||||
|
||||
// Sinexcel S3 stores WorkingMode enum names — map them to Growatt-style display names
|
||||
const sinexcelS3ToDisplayName: Record<string, string> = {
|
||||
'SpontaneousSelfUse': 'LoadPriority',
|
||||
'TimeChargeDischarge': 'BatteryPriority',
|
||||
'PvPriorityCharging': 'GridPriority',
|
||||
};
|
||||
: device === 4 // Sinexcel
|
||||
? [
|
||||
'SpontaneousSelfUse',
|
||||
'TimeChargeDischarge',
|
||||
// 'TimeOfUsePowerPrice',
|
||||
// 'DisasterStandby',
|
||||
// 'ManualControl',
|
||||
'PvPriorityCharging',
|
||||
// 'PrioritySellElectricity'
|
||||
]
|
||||
: [];
|
||||
|
||||
const [errors, setErrors] = useState({
|
||||
minimumSoC: false,
|
||||
|
|
@ -79,21 +81,39 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
const { currentUser, setUser } = context;
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
|
||||
// Resolve S3 OperatingPriority to display index (Sinexcel uses different enum names)
|
||||
const resolveOperatingPriorityIndex = (s3Value: string) => {
|
||||
const displayName = device === 4 ? (sinexcelS3ToDisplayName[s3Value] ?? s3Value) : s3Value;
|
||||
return OperatingPriorityOptions.indexOf(displayName);
|
||||
};
|
||||
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>({
|
||||
minimumSoC: props.values.Config.MinSoc,
|
||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||
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)
|
||||
const pendingConfigKey = `pendingConfig_${props.id}`;
|
||||
|
||||
// Helper to build form values from S3 data
|
||||
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||
// Helper to get current S3 values
|
||||
const getS3Values = () => ({
|
||||
minimumSoC: props.values.Config.MinSoc,
|
||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||
operatingPriority: resolveOperatingPriorityIndex(
|
||||
operatingPriority: OperatingPriorityOptions.indexOf(
|
||||
props.values.Config.OperatingPriority
|
||||
),
|
||||
batteriesCount: props.values.Config.BatteriesCount,
|
||||
|
|
@ -111,83 +131,49 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||
});
|
||||
|
||||
// Restore pending config from localStorage, converting date strings back to Date objects.
|
||||
// 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.
|
||||
// Sync form values when props.values changes
|
||||
// Logic: Use localStorage only briefly after submit to prevent flicker, then trust S3
|
||||
useEffect(() => {
|
||||
const s3Values = getS3Values();
|
||||
const pending = restorePendingConfig();
|
||||
const pendingConfigStr = localStorage.getItem(pendingConfigKey);
|
||||
|
||||
if (pending) {
|
||||
const currentFingerprint = getS3ConfigFingerprint();
|
||||
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
|
||||
if (s3Changed) {
|
||||
// S3 Config changed from snapshot → device uploaded new data → trust S3
|
||||
if (pendingConfigStr) {
|
||||
try {
|
||||
const pendingConfig = JSON.parse(pendingConfigStr);
|
||||
const submittedAt = pendingConfig.submittedAt || 0;
|
||||
const timeSinceSubmit = Date.now() - submittedAt;
|
||||
|
||||
// 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);
|
||||
setFormValues(s3Values);
|
||||
} else {
|
||||
// Still waiting for sync, keep showing submitted values
|
||||
console.log('Waiting for S3 sync, showing submitted values');
|
||||
setFormValues(pendingConfig.values);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout expired: clear localStorage, trust S3 completely
|
||||
console.log('Timeout expired, trusting S3 data');
|
||||
localStorage.removeItem(pendingConfigKey);
|
||||
} catch (e) {
|
||||
localStorage.removeItem(pendingConfigKey);
|
||||
setFormValues(s3Values);
|
||||
} else {
|
||||
// S3 still has same data as at submit time — keep showing pending values
|
||||
setFormValues(pending.values);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No pending config — trust S3 (source of truth)
|
||||
// No localStorage or expired: always use S3 (source of truth)
|
||||
setFormValues(s3Values);
|
||||
}, [props.values]);
|
||||
|
||||
|
|
@ -213,7 +199,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
const stopTimeInMinutes = stopHours * 60 + stopMinutes;
|
||||
|
||||
if (startTimeInMinutes >= stopTimeInMinutes) {
|
||||
setDateSelectionError(intl.formatMessage({ id: 'stopTimeMustBeLater' }));
|
||||
setDateSelectionError('Stop time must be later than start time');
|
||||
setErrorDateModalOpen(true);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -260,15 +246,12 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
setUpdated(true);
|
||||
setLoading(false);
|
||||
|
||||
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
|
||||
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
|
||||
// When S3 Config changes from this snapshot, the device has uploaded new data.
|
||||
const cachePayload = {
|
||||
// Save submitted values to localStorage for optimistic UI update
|
||||
// This ensures the form shows correct values even before S3 syncs (up to 150 sec delay)
|
||||
localStorage.setItem(pendingConfigKey, JSON.stringify({
|
||||
values: formValues,
|
||||
submittedAt: Date.now(),
|
||||
s3ConfigSnapshot: getS3ConfigFingerprint(),
|
||||
};
|
||||
localStorage.setItem(pendingConfigKey, JSON.stringify(cachePayload));
|
||||
submittedAt: Date.now()
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -476,7 +459,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
{/* fullWidth*/}
|
||||
{/*/>*/}
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'minimumSocPercent' })}
|
||||
label="Minimum SoC (%)"
|
||||
name="minimumSoC"
|
||||
value={formValues.minimumSoC}
|
||||
onChange={handleChange}
|
||||
|
|
@ -546,21 +529,21 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
</div>
|
||||
</>
|
||||
|
||||
{/* --- Sinexcel + BatteryPriority (maps to TimeChargeDischarge on device) --- */}
|
||||
{/* --- Sinexcel + TimeChargeDischarge --- */}
|
||||
{device === 4 &&
|
||||
OperatingPriorityOptions[formValues.operatingPriority] ===
|
||||
'BatteryPriority' && (
|
||||
'TimeChargeDischarge' && (
|
||||
<>
|
||||
{/* Power input*/}
|
||||
<div style={{ marginBottom: '5px' }}>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'powerW' })}
|
||||
label="Power (W)"
|
||||
name="timeChargeandDischargePower"
|
||||
value={formValues.timeChargeandDischargePower}
|
||||
onChange={(e) =>
|
||||
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
||||
}
|
||||
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
|
||||
helperText="Enter a positive or negative power value"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -570,7 +553,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DateTimePicker
|
||||
ampm={false}
|
||||
label={intl.formatMessage({ id: 'startDateTime' })}
|
||||
label="Start Date and Time (Start Time < Stop Time)"
|
||||
value={
|
||||
formValues.startTimeChargeandDischargeDayandTime
|
||||
? dayjs(formValues.startTimeChargeandDischargeDayandTime)
|
||||
|
|
@ -602,7 +585,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DateTimePicker
|
||||
ampm={false}
|
||||
label={intl.formatMessage({ id: 'stopDateTime' })}
|
||||
label="Stop Date and Time (Start Time < Stop Time)"
|
||||
value={
|
||||
formValues.stopTimeChargeandDischargeDayandTime
|
||||
? dayjs(formValues.stopTimeChargeandDischargeDayandTime)
|
||||
|
|
@ -668,7 +651,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
|
||||
Successfully applied configuration file
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
@ -688,7 +671,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
|
||||
An error has occurred
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -19,11 +19,8 @@ import {
|
|||
Typography
|
||||
} from '@mui/material';
|
||||
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 axiosConfig from 'src/Resources/axiosConfig';
|
||||
import DailySection from './DailySection';
|
||||
|
||||
interface WeeklyReportProps {
|
||||
installationId: number;
|
||||
|
|
@ -276,7 +273,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
|||
};
|
||||
|
||||
const tabs = [
|
||||
{ label: intl.formatMessage({ id: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
|
||||
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
|
||||
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
|
||||
{ label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' }
|
||||
|
|
@ -285,36 +281,15 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
|||
const safeTab = Math.min(activeTab, tabs.length - 1);
|
||||
|
||||
return (
|
||||
<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
|
||||
value={safeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
sx={{ flex: 1, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
||||
>
|
||||
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => window.print()}
|
||||
sx={{ ml: 2, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }}>
|
||||
<Tabs
|
||||
value={safeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
||||
>
|
||||
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}>
|
||||
<DailySection installationId={installationId} />
|
||||
</Box>
|
||||
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
|
||||
<WeeklySection
|
||||
installationId={installationId}
|
||||
|
|
@ -359,19 +334,19 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
|||
fetchReport();
|
||||
}, [installationId, intl.locale]);
|
||||
|
||||
const fetchReport = async (forceRegenerate = false) => {
|
||||
const fetchReport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await axiosConfig.get('/GetWeeklyReport', {
|
||||
params: { installationId, language: intl.locale, forceRegenerate }
|
||||
params: { installationId, language: intl.locale }
|
||||
});
|
||||
setReport(res.data);
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err.response?.data ||
|
||||
err.message ||
|
||||
intl.formatMessage({ id: 'failedToLoadReport' });
|
||||
'Failed to load report. Make sure the Excel file is placed in tmp_report/';
|
||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -417,7 +392,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
|||
const prev = report.previousWeek;
|
||||
|
||||
const currentWeekDayCount = Math.min(7, report.dailyData.length);
|
||||
const previousWeekDayCount = 7;
|
||||
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount);
|
||||
|
||||
const formatChange = (pct: number) =>
|
||||
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
|
||||
|
|
@ -459,28 +434,15 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
|||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
|
||||
{report.installationName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||
{report.periodStart} — {report.periodEnd}
|
||||
</Typography>
|
||||
</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>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
|
||||
{report.installationName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||
{report.periodStart} — {report.periodEnd}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Weekly Insights */}
|
||||
|
|
@ -703,7 +665,7 @@ function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPe
|
|||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body1" fontWeight="bold" color="#8e44ad">{rec.selfSufficiencyPercent.toFixed(0)}%</Typography>
|
||||
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Energy Independence" /></Typography>
|
||||
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Self-Sufficiency" /></Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
|
|
@ -798,7 +760,6 @@ function MonthlySection({
|
|||
countFn={(r: MonthlyReport) => r.weekCount}
|
||||
sendEndpoint="/SendMonthlyReportEmail"
|
||||
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
||||
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
|
||||
/>
|
||||
) : pendingMonths.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
|
|
@ -872,7 +833,6 @@ function YearlySection({
|
|||
countFn={(r: YearlyReport) => r.monthCount}
|
||||
sendEndpoint="/SendYearlyReportEmail"
|
||||
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
||||
onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
|
||||
/>
|
||||
) : pendingYears.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
|
|
@ -892,8 +852,7 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
countLabelId,
|
||||
countFn,
|
||||
sendEndpoint,
|
||||
sendParamsFn,
|
||||
onRegenerate
|
||||
sendParamsFn
|
||||
}: {
|
||||
reports: T[];
|
||||
type: 'monthly' | 'yearly';
|
||||
|
|
@ -902,11 +861,9 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
countFn: (r: T) => number;
|
||||
sendEndpoint: string;
|
||||
sendParamsFn: (r: T) => object;
|
||||
onRegenerate?: (r: T) => void | Promise<void>;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
|
|
@ -952,22 +909,7 @@ function AggregatedSection<T extends ReportSummary>({
|
|||
))}
|
||||
</Select>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<EmailBar onSend={handleSendEmail} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -1074,7 +1016,7 @@ function InsightBox({ text, bullets }: { text: string; bullets: string[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
|
||||
function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
|
||||
intl: any;
|
||||
energySaved: number;
|
||||
savingsCHF: number;
|
||||
|
|
|
|||
|
|
@ -416,7 +416,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
icon={tab.icon}
|
||||
component={Link}
|
||||
label={tab.label}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
to={
|
||||
tab.value === 'list' || tab.value === 'tree'
|
||||
? routes[tab.value]
|
||||
|
|
@ -483,7 +482,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
component={Link}
|
||||
label={tab.label}
|
||||
to={routes[tab.value]}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
JSONRecordData
|
||||
} from '../Log/graph.util';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface TopologyProps {
|
||||
values: JSONRecordData;
|
||||
|
|
@ -65,10 +64,10 @@ function Topology(props: TopologyProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
Unable to communicate with the installation
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
JSONRecordData
|
||||
} from '../Log/graph.util';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface TopologySodistoreHomeProps {
|
||||
values: JSONRecordData;
|
||||
|
|
@ -39,31 +38,29 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
|
||||
const isMobile = window.innerWidth <= 1490;
|
||||
|
||||
const inv = props.values?.InverterRecord;
|
||||
const hasDevices = !!inv?.Devices;
|
||||
const totalBatteryPower: number = Number(
|
||||
props.values && props.values.InverterRecord
|
||||
? Array.from({ length: props.batteryClusterNumber }).reduce(
|
||||
(sum: number, _, index) => {
|
||||
const i = index + 1;
|
||||
|
||||
const totalBatteryPower: number = hasDevices
|
||||
? (inv?.TotalBatteryPower ?? 0)
|
||||
: Number(
|
||||
Array.from({ length: props.batteryClusterNumber }).reduce(
|
||||
(sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
const rawPower =
|
||||
props.values.InverterRecord[`Battery${i}Power`] as unknown;
|
||||
|
||||
const pvPower: number = hasDevices
|
||||
? (inv?.TotalPhotovoltaicPower ?? 0)
|
||||
: (inv?.PvPower ??
|
||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
||||
.map((key) => inv?.[key] ?? 0)
|
||||
.reduce((sum, val) => sum + val, 0));
|
||||
const power = Number(rawPower) || 0;
|
||||
|
||||
const totalLoadPower: number = hasDevices
|
||||
? (inv?.TotalLoadPower ?? 0)
|
||||
: (inv?.ConsumptionPower ?? 0);
|
||||
return sum + power;
|
||||
},
|
||||
0
|
||||
)
|
||||
: 0
|
||||
);
|
||||
|
||||
const totalGridPower: number =
|
||||
inv?.TotalGridPower ?? inv?.GridPower ?? 0;
|
||||
const pvPower =
|
||||
props.values?.InverterRecord?.PvPower ??
|
||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
||||
.map((key) => props.values?.InverterRecord?.[key] ?? 0)
|
||||
.reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
||||
|
|
@ -85,10 +82,10 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
Unable to communicate with the installation
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
Please wait or refresh the page
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -144,7 +141,7 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
data: props.values?.InverterRecord
|
||||
? [
|
||||
{
|
||||
value: totalGridPower,
|
||||
value: props.values.InverterRecord.GridPower,
|
||||
unit: 'W'
|
||||
}
|
||||
]
|
||||
|
|
@ -156,14 +153,14 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
orientation: 'horizontal',
|
||||
data: props.values?.InverterRecord
|
||||
? {
|
||||
value: totalGridPower,
|
||||
value: props.values.InverterRecord.GridPower,
|
||||
unit: 'W'
|
||||
}
|
||||
: undefined,
|
||||
amount: props.values?.InverterRecord
|
||||
? getAmount(
|
||||
highestConnectionValue,
|
||||
totalGridPower
|
||||
props.values.InverterRecord.GridPower
|
||||
)
|
||||
: 0,
|
||||
showValues: showValues
|
||||
|
|
@ -227,7 +224,7 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
data: props.values?.InverterRecord
|
||||
? [
|
||||
{
|
||||
value: totalLoadPower,
|
||||
value: props.values.InverterRecord.ConsumptionPower,
|
||||
unit: 'W'
|
||||
}
|
||||
]
|
||||
|
|
@ -239,14 +236,14 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
position: 'bottom',
|
||||
data: props.values?.InverterRecord
|
||||
? {
|
||||
value: totalLoadPower,
|
||||
value: props.values.InverterRecord.ConsumptionPower,
|
||||
unit: 'W'
|
||||
}
|
||||
: undefined,
|
||||
amount: props.values?.InverterRecord
|
||||
? getAmount(
|
||||
highestConnectionValue,
|
||||
totalLoadPower
|
||||
props.values.InverterRecord.ConsumptionPower
|
||||
)
|
||||
: 0,
|
||||
showValues: showValues
|
||||
|
|
@ -256,32 +253,23 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
/>
|
||||
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
|
||||
{Array.from({ length: props.batteryClusterNumber }).map((_, index) => {
|
||||
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;
|
||||
}
|
||||
const i = index + 1; // battery cluster index starting from 1
|
||||
|
||||
return (
|
||||
<TopologyColumn
|
||||
key={index + 1}
|
||||
key={i}
|
||||
centerBox={{
|
||||
title: `Battery C${index + 1}`,
|
||||
data: inv
|
||||
title: `Battery C${i}`,
|
||||
data: props.values.InverterRecord
|
||||
? [
|
||||
{ value: soc, unit: '%' },
|
||||
{ value: power, unit: 'W' }
|
||||
{
|
||||
value: props.values.InverterRecord[`Battery${i}Soc`],
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
value: props.values.InverterRecord[`Battery${i}Power`],
|
||||
unit: 'W'
|
||||
}
|
||||
]
|
||||
: undefined,
|
||||
connected: true
|
||||
|
|
|
|||
|
|
@ -177,14 +177,14 @@ function TreeInformation(props: TreeInformationProps) {
|
|||
gutterBottom
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
<FormattedMessage id="confirmDeleteFolder" defaultMessage="Do you want to delete this folder?" />
|
||||
Do you want to delete this folder?
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
gutterBottom
|
||||
sx={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
<FormattedMessage id="deleteFolderWarning" defaultMessage="All installations of this folder will be deleted." />
|
||||
All installations of this folder will be deleted.
|
||||
</Typography>
|
||||
|
||||
<div
|
||||
|
|
@ -204,7 +204,7 @@ function TreeInformation(props: TreeInformationProps) {
|
|||
}}
|
||||
onClick={deleteFolderModalHandle}
|
||||
>
|
||||
<FormattedMessage id="delete" defaultMessage="Delete" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -217,7 +217,7 @@ function TreeInformation(props: TreeInformationProps) {
|
|||
}}
|
||||
onClick={deleteFolderModalHandleCancel}
|
||||
>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
||||
import User from './User';
|
||||
|
||||
|
|
@ -58,8 +57,8 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
|
|||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"></TableCell>
|
||||
<TableCell><FormattedMessage id="username" defaultMessage="Username" /></TableCell>
|
||||
<TableCell><FormattedMessage id="email" defaultMessage="Email" /></TableCell>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import axiosConfig from 'src/Resources/axiosConfig';
|
|||
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
||||
import { TokenContext } from 'src/contexts/tokenContext';
|
||||
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import UserAccess from '../ManageAccess/UserAccess';
|
||||
|
||||
interface singleUserProps {
|
||||
|
|
@ -35,7 +35,6 @@ interface singleUserProps {
|
|||
|
||||
function User(props: singleUserProps) {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [updated, setUpdated] = useState(false);
|
||||
|
|
@ -44,8 +43,8 @@ function User(props: singleUserProps) {
|
|||
const tokencontext = useContext(TokenContext);
|
||||
const { removeToken } = tokencontext;
|
||||
const tabs = [
|
||||
{ value: 'user', label: intl.formatMessage({ id: 'user' }) },
|
||||
{ value: 'manage', label: intl.formatMessage({ id: 'accessManagement' }) }
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'manage', label: 'Access Management' }
|
||||
];
|
||||
const [openModalDeleteUser, setOpenModalDeleteUser] = useState(false);
|
||||
|
||||
|
|
@ -191,7 +190,7 @@ function User(props: singleUserProps) {
|
|||
gutterBottom
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
<FormattedMessage id="confirmDeleteUser" defaultMessage="Do you want to delete this user?" />
|
||||
Do you want to delete this user?
|
||||
</Typography>
|
||||
|
||||
<div
|
||||
|
|
@ -211,7 +210,7 @@ function User(props: singleUserProps) {
|
|||
}}
|
||||
onClick={deleteUserModalHandle}
|
||||
>
|
||||
<FormattedMessage id="delete" defaultMessage="Delete" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -224,7 +223,7 @@ function User(props: singleUserProps) {
|
|||
}}
|
||||
onClick={deleteUserModalHandleCancel}
|
||||
>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
|
@ -275,7 +274,7 @@ function User(props: singleUserProps) {
|
|||
>
|
||||
<div>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'name' })}
|
||||
label="Name"
|
||||
name="name"
|
||||
value={formValues.name}
|
||||
onChange={handleChange}
|
||||
|
|
@ -284,7 +283,7 @@ function User(props: singleUserProps) {
|
|||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'email' })}
|
||||
label="Email"
|
||||
name="email"
|
||||
value={formValues.email}
|
||||
onChange={handleChange}
|
||||
|
|
@ -294,7 +293,7 @@ function User(props: singleUserProps) {
|
|||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={intl.formatMessage({ id: 'information' })}
|
||||
label="Information"
|
||||
name="information"
|
||||
value={formValues.information}
|
||||
onChange={handleChange}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@ import { AccessContext } from '../../../contexts/AccessContextProvider';
|
|||
import Button from '@mui/material/Button';
|
||||
import UserForm from './userForm';
|
||||
import { UserContext } from '../../../contexts/userContext';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
|
||||
function UsersSearch() {
|
||||
const intl = useIntl();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { availableUsers, fetchAvailableUsers } = useContext(AccessContext);
|
||||
const [filteredData, setFilteredData] = useState(availableUsers);
|
||||
|
|
@ -148,7 +147,7 @@ function UsersSearch() {
|
|||
<Grid item xs={12} md={isMobile ? 5 : 3}>
|
||||
<FormControl variant="outlined" fullWidth>
|
||||
<TextField
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { InnovEnergyUser } from 'src/interfaces/UserTypes';
|
|||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { TokenContext } from 'src/contexts/tokenContext';
|
||||
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface userFormProps {
|
||||
cancel: () => void;
|
||||
|
|
@ -27,11 +27,10 @@ interface userFormProps {
|
|||
}
|
||||
|
||||
function userForm(props: userFormProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' }));
|
||||
const [errormessage, setErrorMessage] = useState('An error has occured');
|
||||
const [openInstallation, setOpenInstallation] = useState(false);
|
||||
const [openFolder, setOpenFolder] = useState(false);
|
||||
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
|
||||
|
|
@ -175,7 +174,7 @@ function userForm(props: userFormProps) {
|
|||
.delete(`/DeleteUser?userId=${res.data.id}`)
|
||||
.then((response) => {
|
||||
setLoading(false);
|
||||
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
|
||||
setErrorMessage('An error has occured');
|
||||
setError(true);
|
||||
setTimeout(() => {
|
||||
props.cancel();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
import { Helmet } from 'react-helmet-async';
|
||||
import RefreshTwoToneIcon from '@mui/icons-material/RefreshTwoTone';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const GridWrapper = styled(Grid)(
|
||||
({ theme }) => `
|
||||
|
|
@ -78,7 +77,7 @@ function Status500() {
|
|||
src="/static/images/status/500.svg"
|
||||
/>
|
||||
<Typography variant="h2" sx={{ my: 2 }}>
|
||||
<FormattedMessage id="serverError" defaultMessage="There was an error, please try again later" />
|
||||
There was an error, please try again later
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
|
|
@ -86,7 +85,8 @@ function Status500() {
|
|||
fontWeight="normal"
|
||||
sx={{ mb: 4 }}
|
||||
>
|
||||
<FormattedMessage id="serverInternalError" defaultMessage="The server encountered an internal error and was not able to complete your request" />
|
||||
The server encountered an internal error and was not able to
|
||||
complete your request
|
||||
</Typography>
|
||||
<LoadingButton
|
||||
onClick={handleClick}
|
||||
|
|
@ -95,10 +95,10 @@ function Status500() {
|
|||
color="primary"
|
||||
startIcon={<RefreshTwoToneIcon />}
|
||||
>
|
||||
<FormattedMessage id="refreshView" defaultMessage="Refresh view" />
|
||||
Refresh view
|
||||
</LoadingButton>
|
||||
<Button href="/overview" variant="contained" sx={{ ml: 1 }}>
|
||||
<FormattedMessage id="goBack" defaultMessage="Go back" />
|
||||
Go back
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const InstallationsContextProvider = ({
|
|||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
applyBatchUpdates();
|
||||
}, 2000);
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(timer); // Cleanup timer on component unmount
|
||||
}, [applyBatchUpdates]);
|
||||
|
|
@ -132,7 +132,7 @@ const InstallationsContextProvider = ({
|
|||
socket.current.close();
|
||||
socket.current = null;
|
||||
}
|
||||
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||
const tokenString = localStorage.getItem('token');
|
||||
const token = tokenString !== null ? tokenString : '';
|
||||
const urlWithToken = `wss://monitor.inesco.energy/api/CreateWebSocket?authToken=${token}`;
|
||||
|
||||
|
|
@ -156,15 +156,8 @@ const InstallationsContextProvider = ({
|
|||
|
||||
new_socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data); // Parse the JSON data
|
||||
|
||||
// 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) {
|
||||
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
|
||||
updateInstallationStatus(
|
||||
message.id,
|
||||
message.status,
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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
|
||||
interface TokenContextType {
|
||||
token?: string | null;
|
||||
setNewToken: (new_token: string, rememberMe?: boolean) => void;
|
||||
setNewToken: (new_token: string) => void;
|
||||
removeToken: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -12,29 +12,20 @@ export const TokenContext = createContext<TokenContextType | 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
|
||||
export const TokenContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [token, setToken] = useState(getStoredToken);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
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, rememberMe = false) => {
|
||||
const saveToken = (new_token: string) => {
|
||||
setToken(new_token);
|
||||
if (rememberMe) {
|
||||
localStorage.setItem('token', new_token);
|
||||
sessionStorage.removeItem('token');
|
||||
} else {
|
||||
sessionStorage.setItem('token', new_token);
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
localStorage.setItem('token', new_token);
|
||||
};
|
||||
|
||||
const deleteToken = () => {
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('token');
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {InnovEnergyUser} from '../interfaces/UserTypes';
|
|||
// Define the shape of the context
|
||||
interface UserContextType {
|
||||
currentUser?: InnovEnergyUser;
|
||||
setUser: (user: InnovEnergyUser, rememberMe?: boolean) => void;
|
||||
setUser: (user: InnovEnergyUser) => void;
|
||||
removeUser: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -13,26 +13,20 @@ export const UserContext = createContext<UserContextType | undefined>(
|
|||
undefined
|
||||
);
|
||||
|
||||
const getStoredUser = (): InnovEnergyUser | null => {
|
||||
const data = localStorage.getItem('currentUser') || sessionStorage.getItem('currentUser');
|
||||
return data ? JSON.parse(data) : null;
|
||||
};
|
||||
|
||||
// Create a UserContextProvider component
|
||||
export const UserContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [currentUser, setUser] = useState<InnovEnergyUser>(getStoredUser);
|
||||
//Initialize context state with a "null" user
|
||||
const [currentUser, setUser] = useState<InnovEnergyUser>(
|
||||
JSON.parse(localStorage.getItem('currentUser'))
|
||||
);
|
||||
|
||||
const saveUser = (new_user: InnovEnergyUser, rememberMe?: boolean) => {
|
||||
const saveUser = (new_user: InnovEnergyUser) => {
|
||||
setUser(new_user);
|
||||
const storage = rememberMe !== undefined
|
||||
? (rememberMe ? localStorage : sessionStorage)
|
||||
: (localStorage.getItem('currentUser') ? localStorage : sessionStorage);
|
||||
storage.setItem('currentUser', JSON.stringify(new_user));
|
||||
localStorage.setItem('currentUser', JSON.stringify(new_user));
|
||||
};
|
||||
|
||||
const deleteUser = () => {
|
||||
localStorage.removeItem('currentUser');
|
||||
sessionStorage.removeItem('currentUser');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -32,22 +32,6 @@ 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(
|
||||
method: string,
|
||||
s3Path: string,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export interface chartAggregatedDataInterface {
|
|||
gridImportPower: { name: string; data: number[] };
|
||||
gridExportPower: { name: string; data: number[] };
|
||||
heatingPower: { name: string; data: number[] };
|
||||
acLoad: { name: string; data: number[] };
|
||||
}
|
||||
|
||||
export interface chartDataInterface {
|
||||
|
|
@ -517,27 +516,36 @@ export const transformInputToDailyDataJson = async (
|
|||
let value: number | undefined = undefined;
|
||||
|
||||
if (product === 2) {
|
||||
// SodioHome: use top-level aggregated values (Sinexcel multi-inverter)
|
||||
// SodioHome: custom extraction with fallbacks for Growatt/Sinexcel
|
||||
const inv = result?.InverterRecord;
|
||||
if (inv) {
|
||||
switch (category_index) {
|
||||
case 0: // soc
|
||||
value = inv.AvgBatterySoc ?? inv.Battery1Soc;
|
||||
value = inv.Battery1Soc;
|
||||
break;
|
||||
case 1: // temperature
|
||||
value = inv.AvgBatteryTemp ?? inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
|
||||
// Growatt: Battery1AmbientTemperature, Sinexcel: Battery1Temperature
|
||||
value = inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
|
||||
break;
|
||||
case 2: // battery power
|
||||
value = inv.TotalBatteryPower ?? inv.Battery1Power;
|
||||
value = inv.Battery1Power;
|
||||
break;
|
||||
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;
|
||||
break;
|
||||
case 4: // pv production
|
||||
value = inv.TotalPhotovoltaicPower ?? inv.PvPower ?? inv.PvTotalPower;
|
||||
// Growatt: PvPower (aggregated), Sinexcel: PvTotalPower or sum PvPower1-4
|
||||
value =
|
||||
inv.PvPower ??
|
||||
inv.PvTotalPower ??
|
||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
||||
.map((key) => inv[key] ?? 0)
|
||||
.reduce((sum, val) => sum + val, 0);
|
||||
break;
|
||||
case 6: // consumption
|
||||
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
|
||||
value = inv.ConsumptionPower;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -605,10 +613,6 @@ export const transformInputToDailyDataJson = async (
|
|||
'(' + prefixes[chartOverview['pvProduction'].magnitude] + 'W' + ')';
|
||||
chartOverview.dcBusVoltage.unit =
|
||||
'(' + prefixes[chartOverview['dcBusVoltage'].magnitude] + 'V' + ')';
|
||||
chartOverview.ACLoad.unit =
|
||||
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
|
||||
chartOverview.DCLoad.unit =
|
||||
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
|
||||
|
||||
chartOverview.overview = {
|
||||
magnitude: Math.max(
|
||||
|
|
@ -660,8 +664,7 @@ const fetchJsonDataForOneTime = async (
|
|||
export const transformInputToAggregatedDataJson = async (
|
||||
s3Credentials: I_S3Credentials,
|
||||
start_date: dayjs.Dayjs,
|
||||
end_date: dayjs.Dayjs,
|
||||
product?: number
|
||||
end_date: dayjs.Dayjs
|
||||
): Promise<{
|
||||
chartAggregatedData: chartAggregatedDataInterface;
|
||||
chartOverview: overviewInterface;
|
||||
|
|
@ -682,8 +685,7 @@ export const transformInputToAggregatedDataJson = async (
|
|||
'ChargingBatteryPower',
|
||||
'GridImportPower',
|
||||
'GridExportPower',
|
||||
'HeatingPower',
|
||||
'LoadPowerConsumption'
|
||||
'HeatingPower'
|
||||
];
|
||||
|
||||
const categories = [
|
||||
|
|
@ -705,8 +707,7 @@ export const transformInputToAggregatedDataJson = async (
|
|||
heatingPower: { name: 'Heating Energy', data: [] },
|
||||
dcDischargingPower: { name: 'Discharging Battery Energy', data: [] },
|
||||
gridImportPower: { name: 'Grid Import Energy', data: [] },
|
||||
gridExportPower: { name: 'Grid Export Energy', data: [] },
|
||||
acLoad: { name: 'AC Load', data: [] }
|
||||
gridExportPower: { name: 'Grid Export Energy', data: [] }
|
||||
};
|
||||
|
||||
const chartOverview: overviewInterface = {
|
||||
|
|
@ -735,11 +736,8 @@ export const transformInputToAggregatedDataJson = async (
|
|||
const timestampPromises = [];
|
||||
|
||||
while (currentDay.isBefore(end_date)) {
|
||||
const dateFormat = product === 2
|
||||
? currentDay.format('DDMMYYYY')
|
||||
: currentDay.format('YYYY-MM-DD');
|
||||
timestampPromises.push(
|
||||
fetchAggregatedDataJson(dateFormat, s3Credentials, product)
|
||||
fetchAggregatedDataJson(currentDay.format('YYYY-MM-DD'), s3Credentials)
|
||||
);
|
||||
currentDay = currentDay.add(1, 'day');
|
||||
}
|
||||
|
|
@ -868,16 +866,6 @@ export const transformInputToAggregatedDataJson = async (
|
|||
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 = {
|
||||
magnitude: 0,
|
||||
unit: '(kWh)',
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export interface I_Installation extends I_S3Credentials {
|
|||
testingMode?: boolean;
|
||||
status?: number;
|
||||
serialNumber?: string;
|
||||
networkProvider: string;
|
||||
}
|
||||
|
||||
export interface I_Folder {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
"alarms": "Alarme",
|
||||
"applyChanges": "Änderungen speichern",
|
||||
"country": "Land",
|
||||
"networkProvider": "Netzbetreiber",
|
||||
"createNewFolder": "Neuer Ordner",
|
||||
"createNewUser": "Neuer Benutzer",
|
||||
"customerName": "Kundenname",
|
||||
|
|
@ -107,8 +106,8 @@
|
|||
"daysOfYourUsage": "Tage Ihres Verbrauchs",
|
||||
"estMoneySaved": "Geschätzte Ersparnisse",
|
||||
"atCHFRate": "bei 0,39 CHF/kWh Ø",
|
||||
"solarCoverage": "Energieunabhängigkeit",
|
||||
"fromSolarSub": "aus eigenem Solar + Batterie System",
|
||||
"solarCoverage": "Eigenversorgung",
|
||||
"fromSolarSub": "aus Solar + Batterie",
|
||||
"avgDailyConsumption": "Ø Tagesverbrauch",
|
||||
"batteryEfficiency": "Batterieeffizienz",
|
||||
"batteryEffSub": "Entladung vs. Ladung",
|
||||
|
|
@ -140,22 +139,6 @@
|
|||
"weeklyTab": "Wöchentlich",
|
||||
"monthlyTab": "Monatlich",
|
||||
"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",
|
||||
"yearlyReportTitle": "Jährlicher Leistungsbericht",
|
||||
"monthlyInsights": "Monatliche Einblicke",
|
||||
|
|
@ -172,7 +155,6 @@
|
|||
"availableForGeneration": "Zur Generierung verfügbar",
|
||||
"generateMonth": "{month} {year} generieren ({count} Wochen)",
|
||||
"generateYear": "{year} generieren ({count} Monate)",
|
||||
"regenerateReport": "Neu generieren",
|
||||
"generatingMonthly": "Wird generiert...",
|
||||
"generatingYearly": "Wird generiert...",
|
||||
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
||||
|
|
@ -182,9 +164,9 @@
|
|||
"ai_show_less": "Weniger anzeigen",
|
||||
"ai_likely_causes": "Wahrscheinliche Ursachen:",
|
||||
"ai_next_steps": "Empfohlene nächste Schritte:",
|
||||
"demo_test_button": "KI-Diagnose",
|
||||
"demo_hide_button": "KI-Diagnose ausblenden",
|
||||
"demo_panel_title": "KI-Diagnose",
|
||||
"demo_test_button": "KI-Diagnose testen",
|
||||
"demo_hide_button": "KI-Diagnose Demo ausblenden",
|
||||
"demo_panel_title": "KI-Diagnose Demo",
|
||||
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
|
||||
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
|
||||
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
|
||||
|
|
@ -422,98 +404,5 @@
|
|||
"allInstallations": "Alle Installationen",
|
||||
"group": "Gruppe",
|
||||
"groups": "Gruppen",
|
||||
"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."
|
||||
"requiredOrderNumber": "Pflichtbestellnummer"
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
"allInstallations": "All installations",
|
||||
"applyChanges": "Apply changes",
|
||||
"country": "Country",
|
||||
"networkProvider": "Network Provider",
|
||||
"customerName": "Customer name",
|
||||
"english": "English",
|
||||
"german": "German",
|
||||
|
|
@ -89,8 +88,8 @@
|
|||
"daysOfYourUsage": "days of your usage",
|
||||
"estMoneySaved": "Est. Money Saved",
|
||||
"atCHFRate": "at 0.39 CHF/kWh avg.",
|
||||
"solarCoverage": "Energy Independence",
|
||||
"fromSolarSub": "from your own solar + battery system",
|
||||
"solarCoverage": "Self-Sufficiency",
|
||||
"fromSolarSub": "from solar + battery",
|
||||
"avgDailyConsumption": "Avg Daily Consumption",
|
||||
"batteryEfficiency": "Battery Efficiency",
|
||||
"batteryEffSub": "discharge vs charge",
|
||||
|
|
@ -122,22 +121,6 @@
|
|||
"weeklyTab": "Weekly",
|
||||
"monthlyTab": "Monthly",
|
||||
"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",
|
||||
"yearlyReportTitle": "Annual Performance Report",
|
||||
"monthlyInsights": "Monthly Insights",
|
||||
|
|
@ -154,7 +137,6 @@
|
|||
"availableForGeneration": "Available for Generation",
|
||||
"generateMonth": "Generate {month} {year} ({count} weeks)",
|
||||
"generateYear": "Generate {year} ({count} months)",
|
||||
"regenerateReport": "Regenerate",
|
||||
"generatingMonthly": "Generating...",
|
||||
"generatingYearly": "Generating...",
|
||||
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
||||
|
|
@ -164,104 +146,11 @@
|
|||
"ai_show_less": "Show less",
|
||||
"ai_likely_causes": "Likely causes:",
|
||||
"ai_next_steps": "Suggested next steps:",
|
||||
"demo_test_button": "AI Diagnosis",
|
||||
"demo_hide_button": "Hide AI Diagnosis",
|
||||
"demo_panel_title": "AI Diagnosis",
|
||||
"demo_test_button": "Test AI Diagnosis",
|
||||
"demo_hide_button": "Hide AI Diagnosis Demo",
|
||||
"demo_panel_title": "AI Diagnosis Demo",
|
||||
"demo_custom_group": "Custom (may use Mistral AI)",
|
||||
"demo_custom_option": "Type custom alarm below…",
|
||||
"demo_custom_placeholder": "e.g. UnknownBatteryFault",
|
||||
"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."
|
||||
"demo_diagnose_button": "Diagnose"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"alarms": "Alarmes",
|
||||
"applyChanges": "Appliquer",
|
||||
"country": "Pays",
|
||||
"networkProvider": "Gestionnaire de réseau",
|
||||
"createNewFolder": "Nouveau dossier",
|
||||
"createNewUser": "Nouvel utilisateur",
|
||||
"customerName": "Nom du client",
|
||||
|
|
@ -101,8 +100,8 @@
|
|||
"daysOfYourUsage": "jours de votre consommation",
|
||||
"estMoneySaved": "Économies estimées",
|
||||
"atCHFRate": "à 0,39 CHF/kWh moy.",
|
||||
"solarCoverage": "Indépendance énergétique",
|
||||
"fromSolarSub": "de votre système solaire + batterie",
|
||||
"solarCoverage": "Autosuffisance",
|
||||
"fromSolarSub": "du solaire + batterie",
|
||||
"avgDailyConsumption": "Conso. quotidienne moy.",
|
||||
"batteryEfficiency": "Efficacité de la batterie",
|
||||
"batteryEffSub": "décharge vs charge",
|
||||
|
|
@ -134,22 +133,6 @@
|
|||
"weeklyTab": "Hebdomadaire",
|
||||
"monthlyTab": "Mensuel",
|
||||
"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",
|
||||
"yearlyReportTitle": "Rapport de performance annuel",
|
||||
"monthlyInsights": "Aperçus mensuels",
|
||||
|
|
@ -166,7 +149,6 @@
|
|||
"availableForGeneration": "Disponible pour génération",
|
||||
"generateMonth": "Générer {month} {year} ({count} semaines)",
|
||||
"generateYear": "Générer {year} ({count} mois)",
|
||||
"regenerateReport": "Régénérer",
|
||||
"generatingMonthly": "Génération en cours...",
|
||||
"generatingYearly": "Génération en cours...",
|
||||
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
||||
|
|
@ -176,9 +158,9 @@
|
|||
"ai_show_less": "Afficher moins",
|
||||
"ai_likely_causes": "Causes probables :",
|
||||
"ai_next_steps": "Prochaines étapes suggérées :",
|
||||
"demo_test_button": "Diagnostic IA",
|
||||
"demo_hide_button": "Masquer le diagnostic IA",
|
||||
"demo_panel_title": "Diagnostic IA",
|
||||
"demo_test_button": "Tester le diagnostic IA",
|
||||
"demo_hide_button": "Masquer la démo de diagnostic IA",
|
||||
"demo_panel_title": "Démo de diagnostic IA",
|
||||
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
|
||||
"demo_custom_option": "Saisir une alarme personnalisée…",
|
||||
"demo_custom_placeholder": "ex. UnknownBatteryFault",
|
||||
|
|
@ -422,98 +404,5 @@
|
|||
"groupTabs": "Groupes",
|
||||
"groupTree": "Arborescence de groupes",
|
||||
"installationTabs": "Installations",
|
||||
"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."
|
||||
"navigationTabs": "Navigation"
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
"allInstallations": "Tutte le installazioni",
|
||||
"applyChanges": "Applica modifiche",
|
||||
"country": "Paese",
|
||||
"networkProvider": "Gestore di rete",
|
||||
"customerName": "Nome cliente",
|
||||
"english": "Inglese",
|
||||
"german": "Tedesco",
|
||||
|
|
@ -112,8 +111,8 @@
|
|||
"daysOfYourUsage": "giorni del tuo consumo",
|
||||
"estMoneySaved": "Risparmio stimato",
|
||||
"atCHFRate": "a 0,39 CHF/kWh media",
|
||||
"solarCoverage": "Indipendenza energetica",
|
||||
"fromSolarSub": "dal proprio impianto solare + batteria",
|
||||
"solarCoverage": "Autosufficienza",
|
||||
"fromSolarSub": "da solare + batteria",
|
||||
"avgDailyConsumption": "Consumo medio giornaliero",
|
||||
"batteryEfficiency": "Efficienza della batteria",
|
||||
"batteryEffSub": "scarica vs carica",
|
||||
|
|
@ -145,22 +144,6 @@
|
|||
"weeklyTab": "Settimanale",
|
||||
"monthlyTab": "Mensile",
|
||||
"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",
|
||||
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
|
||||
"monthlyInsights": "Approfondimenti mensili",
|
||||
|
|
@ -177,7 +160,6 @@
|
|||
"availableForGeneration": "Disponibile per la generazione",
|
||||
"generateMonth": "Genera {month} {year} ({count} settimane)",
|
||||
"generateYear": "Genera {year} ({count} mesi)",
|
||||
"regenerateReport": "Rigenera",
|
||||
"generatingMonthly": "Generazione in corso...",
|
||||
"generatingYearly": "Generazione in corso...",
|
||||
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
||||
|
|
@ -187,9 +169,9 @@
|
|||
"ai_show_less": "Mostra meno",
|
||||
"ai_likely_causes": "Cause probabili:",
|
||||
"ai_next_steps": "Passi successivi suggeriti:",
|
||||
"demo_test_button": "Diagnosi IA",
|
||||
"demo_hide_button": "Nascondi diagnosi IA",
|
||||
"demo_panel_title": "Diagnosi IA",
|
||||
"demo_test_button": "Testa diagnosi IA",
|
||||
"demo_hide_button": "Nascondi demo diagnosi IA",
|
||||
"demo_panel_title": "Demo diagnosi IA",
|
||||
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
|
||||
"demo_custom_option": "Inserisci allarme personalizzato…",
|
||||
"demo_custom_placeholder": "es. UnknownBatteryFault",
|
||||
|
|
@ -422,98 +404,5 @@
|
|||
"alarm_AFCIFault": "Guasto AFCI",
|
||||
"alarm_GFCIHigh": "Corrente di guasto a terra elevata",
|
||||
"alarm_PVVoltageHigh": "Tensione PV elevata",
|
||||
"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."
|
||||
"alarm_OffGridBusVoltageTooLow": "Tensione Bus Fuori Rete Troppo Bassa"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ function HeaderMenu(props: HeaderButtonsProps) {
|
|||
sx={{
|
||||
color: isMobile ? 'white' : ''
|
||||
}}
|
||||
data-tour="language-selector"
|
||||
>
|
||||
<List disablePadding component={Box} display="flex">
|
||||
<ListItem
|
||||
|
|
|
|||
|
|
@ -13,12 +13,10 @@ import {
|
|||
useTheme
|
||||
} from '@mui/material';
|
||||
import MenuTwoToneIcon from '@mui/icons-material/MenuTwoTone';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { SidebarContext } from 'src/contexts/SidebarContext';
|
||||
import CloseTwoToneIcon from '@mui/icons-material/CloseTwoTone';
|
||||
import HeaderUserbox from './Userbox';
|
||||
import HeaderMenu from './Menu';
|
||||
import { useTour } from 'src/contexts/TourContext';
|
||||
|
||||
const HeaderWrapper = styled(Box)(
|
||||
({ theme }) => `
|
||||
|
|
@ -46,7 +44,6 @@ interface HeaderProps {
|
|||
|
||||
function Header(props: HeaderProps) {
|
||||
const { sidebarToggle, toggleSidebar } = useContext(SidebarContext);
|
||||
const { startTour } = useTour();
|
||||
const theme = useTheme();
|
||||
const isMobile = window.innerWidth <= 1280;
|
||||
|
||||
|
|
@ -99,14 +96,6 @@ function Header(props: HeaderProps) {
|
|||
></Stack>
|
||||
|
||||
<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
|
||||
language={props.language}
|
||||
onSelectLanguage={props.onSelectLanguage}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,6 @@
|
|||
import { ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { alpha, Box, lighten, useTheme } from '@mui/material';
|
||||
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 { Outlet } from 'react-router-dom';
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
|
|
@ -22,88 +11,11 @@ interface SidebarLayoutProps {
|
|||
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 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 (
|
||||
<>
|
||||
<Joyride
|
||||
steps={tourSteps}
|
||||
run={tourReady}
|
||||
callback={handleJoyrideCallback}
|
||||
continuous
|
||||
showSkipButton
|
||||
showProgress
|
||||
scrollToFirstStep
|
||||
disableOverlayClose
|
||||
styles={{
|
||||
options: {
|
||||
primaryColor: '#ffc04d',
|
||||
zIndex: 10000
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
|
|
|
|||
Loading…
Reference in New Issue