Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
41119565ae
|
|
@ -4,3 +4,4 @@
|
|||
*.DotSettings.user
|
||||
**/.idea/
|
||||
**/.env
|
||||
.claude/
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,29 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Per-language AI insight cache for weekly, monthly, and yearly report summaries.
|
||||
/// Keyed by (ReportType, ReportId, Language) — generated once on first request per language,
|
||||
/// then reused for UI reads and email sends. Never store language-specific text in the
|
||||
/// summary tables themselves; always go through this cache.
|
||||
/// </summary>
|
||||
public class AiInsightCache
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
/// <summary>"weekly" | "monthly" | "yearly"</summary>
|
||||
[Indexed]
|
||||
public String ReportType { get; set; } = "";
|
||||
|
||||
/// <summary>FK to WeeklyReportSummary.Id / MonthlyReportSummary.Id / YearlyReportSummary.Id</summary>
|
||||
[Indexed]
|
||||
public Int64 ReportId { get; set; }
|
||||
|
||||
/// <summary>ISO 639-1 language code: "en" | "de" | "fr" | "it"</summary>
|
||||
public String Language { get; set; } = "en";
|
||||
|
||||
public String InsightText { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
|
@ -48,6 +48,12 @@ public class Configuration
|
|||
$"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+
|
||||
$"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}";
|
||||
}
|
||||
|
||||
// TODO: SodistoreGrid — update configuration fields when defined
|
||||
public string GetConfigurationSodistoreGrid()
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public enum CalibrationChargeType
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Raw daily energy totals for one installation and calendar day.
|
||||
/// Source of truth for weekly and monthly report generation.
|
||||
/// Populated by DailyIngestionService from xlsx (current) or S3 (future).
|
||||
/// Retention: 1 year (cleaned up annually on Jan 2).
|
||||
/// </summary>
|
||||
public class DailyEnergyRecord
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
// ISO date string: "YYYY-MM-DD"
|
||||
public String Date { get; set; } = "";
|
||||
|
||||
// Energy totals (kWh) — cumulative for the full calendar day
|
||||
public Double PvProduction { get; set; }
|
||||
public Double LoadConsumption { get; set; }
|
||||
public Double GridImport { get; set; }
|
||||
public Double GridExport { get; set; }
|
||||
public Double BatteryCharged { get; set; }
|
||||
public Double BatteryDischarged { get; set; }
|
||||
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public class HourlyEnergyRecord
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
// "YYYY-MM-DD" — used for range queries (same pattern as DailyEnergyRecord)
|
||||
[Indexed]
|
||||
public String Date { get; set; } = "";
|
||||
|
||||
// 0–23
|
||||
public Int32 Hour { get; set; }
|
||||
|
||||
// "YYYY-MM-DD HH" — used for idempotency check
|
||||
public String DateHour { get; set; } = "";
|
||||
|
||||
public String DayOfWeek { get; set; } = "";
|
||||
public Boolean IsWeekend { get; set; }
|
||||
|
||||
// Energy for this hour (kWh)
|
||||
public Double PvKwh { get; set; }
|
||||
public Double LoadKwh { get; set; }
|
||||
public Double GridImportKwh { get; set; }
|
||||
public Double BatteryChargedKwh { get; set; }
|
||||
public Double BatteryDischargedKwh { get; set; }
|
||||
|
||||
// Instantaneous state of charge at snapshot time (%)
|
||||
public Double BattSoC { get; set; }
|
||||
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ public enum ProductType
|
|||
Salimax = 0,
|
||||
Salidomo = 1,
|
||||
SodioHome =2,
|
||||
SodiStoreMax=3
|
||||
SodiStoreMax=3,
|
||||
SodistoreGrid=4,
|
||||
SodistorePro=5
|
||||
}
|
||||
|
||||
public enum StatusType
|
||||
|
|
@ -26,6 +28,13 @@ public class Installation : TreeNode
|
|||
public String Location { get; set; } = "";
|
||||
public String Region { get; set; } = "";
|
||||
public String Country { get; set; } = "";
|
||||
public String Street { get; set; } = "";
|
||||
public String PostCode { get; set; } = "";
|
||||
public String City { get; set; } = "";
|
||||
public String Canton { get; set; } = "";
|
||||
public String DistributionPartner { get; set; } = "";
|
||||
public String InverterFirmwareVersion { get; set; } = "";
|
||||
public String BatteryFirmwareVersion { get; set; } = "";
|
||||
public String VpnIp { get; set; } = "";
|
||||
public String InstallationName { get; set; } = "";
|
||||
|
||||
|
|
@ -48,9 +57,14 @@ public class Installation : TreeNode
|
|||
public int BatteryClusterNumber { get; set; } = 0;
|
||||
public int BatteryNumber { get; set; } = 0;
|
||||
public string BatterySerialNumbers { get; set; } = "";
|
||||
|
||||
public string PvStringsPerInverter { get; set; } = "";
|
||||
public string InstallationModel { get; set; } = "";
|
||||
public string ExternalEms { get; set; } = "No";
|
||||
public string CouplingType { get; set; } = "DC";
|
||||
|
||||
[Ignore]
|
||||
public String OrderNumbers { get; set; }
|
||||
public String VrmLink { get; set; } = "";
|
||||
public string Configuration { get; set; } = "";
|
||||
public string NetworkProvider { get; set; } = "";
|
||||
}
|
||||
|
|
@ -145,6 +145,8 @@ public static class ExoCmd
|
|||
const String method = "iam-role";
|
||||
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
|
||||
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
|
||||
|
||||
|
||||
|
|
@ -263,42 +265,70 @@ public static class ExoCmd
|
|||
|
||||
public static async Task<Boolean> RevokeReadKey(this Installation installation)
|
||||
{
|
||||
//Check exoscale documentation https://openapi-v2.exoscale.com/topic/topic-api-request-signature
|
||||
|
||||
var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3Key}";
|
||||
var method = $"access-key/{installation.S3Key}";
|
||||
|
||||
|
||||
var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60;
|
||||
|
||||
var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime);
|
||||
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader);
|
||||
|
||||
var response = await client.DeleteAsync(url);
|
||||
return response.IsSuccessStatusCode;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.DeleteAsync(url);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Successfully revoked read key for installation {installation.Id}.");
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Failed to revoke read key. HTTP Status: {response.StatusCode}. Response: {await response.Content.ReadAsStringAsync()}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error occurred while revoking read key: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<Boolean> RevokeWriteKey(this Installation installation)
|
||||
{
|
||||
//Check exoscale documentation https://openapi-v2.exoscale.com/topic/topic-api-request-signature
|
||||
|
||||
var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3WriteKey}";
|
||||
var method = $"access-key/{installation.S3WriteKey}";
|
||||
|
||||
|
||||
var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60;
|
||||
|
||||
var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime);
|
||||
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader);
|
||||
|
||||
var response = await client.DeleteAsync(url);
|
||||
return response.IsSuccessStatusCode;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.DeleteAsync(url);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Successfully revoked write key for installation {installation.Id}.");
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Failed to revoke write key. HTTP Status: {response.StatusCode}. Response: {await response.Content.ReadAsStringAsync()}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error occurred while revoking write key: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<(String, String)> CreateWriteKey(this Installation installation)
|
||||
|
|
@ -320,6 +350,8 @@ public static class ExoCmd
|
|||
const String method = "iam-role";
|
||||
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
|
||||
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
|
||||
|
||||
var contentString = $$"""
|
||||
|
|
@ -371,19 +403,44 @@ public static class ExoCmd
|
|||
return await s3Region.PutBucket(installation.BucketName()) != null;
|
||||
}
|
||||
|
||||
public static async Task<Boolean> DeleteBucket(this Installation installation)
|
||||
public static async Task<Boolean> PurgeAndDeleteBucket(this Installation installation)
|
||||
{
|
||||
var s3Region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", S3Credentials!);
|
||||
return await s3Region.DeleteBucket(installation.BucketName()) ;
|
||||
var bucket = s3Region.Bucket(installation.BucketName());
|
||||
|
||||
try
|
||||
{
|
||||
var purged = await bucket.PurgeBucket();
|
||||
if (!purged)
|
||||
{
|
||||
Console.WriteLine($"Failed to purge bucket {installation.BucketName()} for installation {installation.Id}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var deleted = await s3Region.DeleteBucket(installation.BucketName());
|
||||
if (!deleted)
|
||||
{
|
||||
Console.WriteLine($"Failed to delete bucket {installation.BucketName()} for installation {installation.Id}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Successfully purged and deleted bucket {installation.BucketName()} for installation {installation.Id}.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error occurred while purging/deleting bucket {installation.BucketName()}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static async Task<Boolean> SendConfig(this Installation installation, Configuration config)
|
||||
{
|
||||
|
||||
var maxRetransmissions = 4;
|
||||
var maxRetransmissions = 6;
|
||||
UdpClient udpClient = new UdpClient();
|
||||
udpClient.Client.ReceiveTimeout = 2000;
|
||||
udpClient.Client.ReceiveTimeout = 3000;
|
||||
int port = 9000;
|
||||
|
||||
Console.WriteLine("Trying to reach installation with IP: " + installation.VpnIp);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ public static class InstallationMethods
|
|||
private static readonly String BucketNameSalt = "3e5b3069-214a-43ee-8d85-57d72000c19d";
|
||||
private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e";
|
||||
private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa";
|
||||
private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8";
|
||||
private static readonly String SodistoreProBucketNameSalt = "325c9373-9025-4a8d-bf5a-f9eedf1f155c";
|
||||
|
||||
public static String BucketName(this Installation installation)
|
||||
{
|
||||
|
|
@ -17,12 +19,22 @@ public static class InstallationMethods
|
|||
{
|
||||
return $"{installation.S3BucketId}-{BucketNameSalt}";
|
||||
}
|
||||
|
||||
|
||||
if (installation.Product == (int)ProductType.SodioHome)
|
||||
{
|
||||
return $"{installation.S3BucketId}-{SodioHomeBucketNameSalt}";
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodistoreGrid)
|
||||
{
|
||||
return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}";
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
return $"{installation.S3BucketId}-{SodistoreProBucketNameSalt}";
|
||||
}
|
||||
|
||||
return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}";
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ public static class SessionMethods
|
|||
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome)
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
return user is not null
|
||||
&& user.UserType != 0
|
||||
|
|
@ -295,9 +295,9 @@ public static class SessionMethods
|
|||
.Apply(Db.Update);
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax)
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
|
||||
|
||||
return user is not null
|
||||
&& installation is not null
|
||||
&& original is not null
|
||||
|
|
@ -305,7 +305,7 @@ public static class SessionMethods
|
|||
&& user.HasAccessTo(installation)
|
||||
&& installation
|
||||
.WithParentOf(original) // prevent moving
|
||||
.Apply(Db.Update);
|
||||
.Apply(Db.Update);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -324,23 +324,22 @@ public static class SessionMethods
|
|||
{
|
||||
var user = session?.User;
|
||||
|
||||
if (user is not null
|
||||
&& installation is not null
|
||||
&& user.UserType != 0)
|
||||
{
|
||||
|
||||
return
|
||||
Db.Delete(installation)
|
||||
&& await installation.RevokeReadKey()
|
||||
&& await installation.RevokeWriteKey()
|
||||
&& await installation.RemoveReadRole()
|
||||
&& await installation.RemoveWriteRole()
|
||||
&& await installation.DeleteBucket();
|
||||
|
||||
}
|
||||
if (user is null || installation is null || user.UserType == 0)
|
||||
return false;
|
||||
|
||||
return false;
|
||||
// Try all Exoscale operations independently (don't short-circuit)
|
||||
var readKeyOk = await installation.RevokeReadKey();
|
||||
var writeKeyOk = await installation.RevokeWriteKey();
|
||||
var readRoleOk = await installation.RemoveReadRole();
|
||||
var writeRoleOk = await installation.RemoveWriteRole();
|
||||
var bucketOk = await installation.PurgeAndDeleteBucket();
|
||||
|
||||
if (!readKeyOk || !writeKeyOk || !readRoleOk || !writeRoleOk || !bucketOk)
|
||||
Console.WriteLine($"[Delete] Partial Exoscale cleanup for installation {installation.Id}: " +
|
||||
$"readKey={readKeyOk}, writeKey={writeKeyOk}, readRole={readRoleOk}, writeRole={writeRoleOk}, bucket={bucketOk}");
|
||||
|
||||
// Always delete from DB (best-effort — admin wants it gone)
|
||||
return Db.Delete(installation);
|
||||
}
|
||||
|
||||
public static Boolean Create(this Session? session, User newUser)
|
||||
|
|
|
|||
|
|
@ -243,22 +243,22 @@ public static class UserMethods
|
|||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
"Passwort Ihres Inesco Energy Kontos zurücksetzen",
|
||||
"Passwort Ihres inesco energy Kontos zurücksetzen",
|
||||
$"Sehr geehrte/r {user.Name}\n" +
|
||||
$"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}"
|
||||
),
|
||||
"fr" => (
|
||||
"Réinitialisation du mot de passe de votre compte Inesco Energy",
|
||||
"Réinitialisation du mot de passe de votre compte inesco energy",
|
||||
$"Cher/Chère {user.Name}\n" +
|
||||
$"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}"
|
||||
),
|
||||
"it" => (
|
||||
"Reimposta la password del tuo account Inesco Energy",
|
||||
"Reimposta la password del tuo account inesco energy",
|
||||
$"Gentile {user.Name}\n" +
|
||||
$"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}"
|
||||
),
|
||||
_ => (
|
||||
"Reset the password of your Inesco Energy Account",
|
||||
"Reset the password of your inesco energy Account",
|
||||
$"Dear {user.Name}\n" +
|
||||
$"To reset your password please open this link: {resetLink}?token={encodedToken}"
|
||||
)
|
||||
|
|
@ -274,28 +274,89 @@ public static class UserMethods
|
|||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
"Ihr neues Inesco Energy Konto",
|
||||
"Ihr neues inesco energy Konto",
|
||||
$"Sehr geehrte/r {user.Name}\n" +
|
||||
$"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
|
||||
$"Um Ihr Passwort festzulegen und sich bei Ihrem inesco energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
|
||||
),
|
||||
"fr" => (
|
||||
"Votre nouveau compte Inesco Energy",
|
||||
"Votre nouveau compte inesco energy",
|
||||
$"Cher/Chère {user.Name}\n" +
|
||||
$"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}"
|
||||
$"Pour définir votre mot de passe et vous connecter à votre compte inesco energy, veuillez ouvrir ce lien : {resetLink}"
|
||||
),
|
||||
"it" => (
|
||||
"Il tuo nuovo account Inesco Energy",
|
||||
"Il tuo nuovo account inesco energy",
|
||||
$"Gentile {user.Name}\n" +
|
||||
$"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}"
|
||||
$"Per impostare la password e accedere al suo account inesco energy, apra questo link: {resetLink}"
|
||||
),
|
||||
_ => (
|
||||
"Your new Inesco Energy Account",
|
||||
"Your new inesco energy Account",
|
||||
$"Dear {user.Name}\n" +
|
||||
$"To set your password and log in to your Inesco Energy Account open this link: {resetLink}"
|
||||
$"To set your password and log in to your inesco energy Account open this link: {resetLink}"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
|
||||
public static Task SendTicketAssignedEmail(this User user, Ticket ticket)
|
||||
{
|
||||
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||
var priority = (TicketPriority)ticket.Priority;
|
||||
var category = (TicketCategory)ticket.Category;
|
||||
|
||||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
$"inesco energy – Ticket #{ticket.Id} wurde Ihnen zugewiesen",
|
||||
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||
$"Ein Ticket wurde Ihnen zugewiesen:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Betreff: {ticket.Subject}\n" +
|
||||
$"Priorität: {priority}\n" +
|
||||
$"Kategorie: {category}\n\n" +
|
||||
$"Beschreibung:\n{ticket.Description}\n\n" +
|
||||
$"Öffnen Sie das Ticket hier: {ticketLink}\n\n" +
|
||||
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||
),
|
||||
"fr" => (
|
||||
$"inesco energy – Le ticket #{ticket.Id} vous a été attribué",
|
||||
$"Cher/Chère {user.Name},\n\n" +
|
||||
$"Un ticket vous a été attribué :\n\n" +
|
||||
$"Ticket : #{ticket.Id}\n" +
|
||||
$"Objet : {ticket.Subject}\n" +
|
||||
$"Priorité : {priority}\n" +
|
||||
$"Catégorie : {category}\n\n" +
|
||||
$"Description :\n{ticket.Description}\n\n" +
|
||||
$"Ouvrir le ticket : {ticketLink}\n\n" +
|
||||
"Cordialement,\ninesco energy Monitor"
|
||||
),
|
||||
"it" => (
|
||||
$"inesco energy – Il ticket #{ticket.Id} le è stato assegnato",
|
||||
$"Gentile {user.Name},\n\n" +
|
||||
$"Le è stato assegnato un ticket:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Oggetto: {ticket.Subject}\n" +
|
||||
$"Priorità: {priority}\n" +
|
||||
$"Categoria: {category}\n\n" +
|
||||
$"Descrizione:\n{ticket.Description}\n\n" +
|
||||
$"Aprire il ticket: {ticketLink}\n\n" +
|
||||
"Cordiali saluti,\ninesco energy Monitor"
|
||||
),
|
||||
_ => (
|
||||
$"inesco energy – Ticket #{ticket.Id} has been assigned to you",
|
||||
$"Dear {user.Name},\n\n" +
|
||||
$"A ticket has been assigned to you:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Subject: {ticket.Subject}\n" +
|
||||
$"Priority: {priority}\n" +
|
||||
$"Category: {category}\n\n" +
|
||||
$"Description:\n{ticket.Description}\n\n" +
|
||||
$"Open the ticket: {ticketLink}\n\n" +
|
||||
"Best regards,\ninesco energy Monitor"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Stored summary for a weekly report period.
|
||||
/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation.
|
||||
/// </summary>
|
||||
public class WeeklyReportSummary
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
// Period boundaries (ISO date strings: "2026-02-10")
|
||||
public String PeriodStart { get; set; } = "";
|
||||
public String PeriodEnd { get; set; } = "";
|
||||
|
||||
// Energy totals (kWh)
|
||||
public Double TotalPvProduction { get; set; }
|
||||
public Double TotalConsumption { get; set; }
|
||||
public Double TotalGridImport { get; set; }
|
||||
public Double TotalGridExport { get; set; }
|
||||
public Double TotalBatteryCharged { get; set; }
|
||||
public Double TotalBatteryDischarged { get; set; }
|
||||
|
||||
// Derived metrics
|
||||
public Double TotalEnergySaved { get; set; }
|
||||
public Double TotalSavingsCHF { get; set; }
|
||||
public Double SelfSufficiencyPercent { get; set; }
|
||||
public Double SelfConsumptionPercent { get; set; }
|
||||
public Double BatteryEfficiencyPercent { get; set; }
|
||||
public Double GridDependencyPercent { get; set; }
|
||||
|
||||
// Behavioral highlights
|
||||
public Int32 PeakLoadHour { get; set; }
|
||||
public Int32 PeakSolarHour { get; set; }
|
||||
public Double WeekdayAvgDailyLoad { get; set; }
|
||||
public Double WeekendAvgDailyLoad { get; set; }
|
||||
|
||||
// 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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated monthly report. Created from weekly summaries at month-end.
|
||||
/// Consumed and deleted by yearly aggregation.
|
||||
/// </summary>
|
||||
public class MonthlyReportSummary
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 Month { get; set; }
|
||||
public String PeriodStart { get; set; } = "";
|
||||
public String PeriodEnd { get; set; } = "";
|
||||
|
||||
// Aggregated energy totals
|
||||
public Double TotalPvProduction { get; set; }
|
||||
public Double TotalConsumption { get; set; }
|
||||
public Double TotalGridImport { get; set; }
|
||||
public Double TotalGridExport { get; set; }
|
||||
public Double TotalBatteryCharged { get; set; }
|
||||
public Double TotalBatteryDischarged { get; set; }
|
||||
|
||||
// Re-derived from aggregated totals
|
||||
public Double TotalEnergySaved { get; set; }
|
||||
public Double TotalSavingsCHF { get; set; }
|
||||
public Double SelfSufficiencyPercent { get; set; }
|
||||
public Double SelfConsumptionPercent { get; set; }
|
||||
public Double BatteryEfficiencyPercent { get; set; }
|
||||
public Double GridDependencyPercent { get; set; }
|
||||
|
||||
// Averaged behavioral highlights
|
||||
public Int32 AvgPeakLoadHour { get; set; }
|
||||
public Int32 AvgPeakSolarHour { get; set; }
|
||||
public Double AvgWeekdayDailyLoad { get; set; }
|
||||
public Double AvgWeekendDailyLoad { get; set; }
|
||||
|
||||
public Int32 WeekCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated yearly report. Created from monthly summaries at year-end.
|
||||
/// Kept indefinitely.
|
||||
/// </summary>
|
||||
public class YearlyReportSummary
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
public Int32 Year { get; set; }
|
||||
public String PeriodStart { get; set; } = "";
|
||||
public String PeriodEnd { get; set; } = "";
|
||||
|
||||
// Aggregated energy totals
|
||||
public Double TotalPvProduction { get; set; }
|
||||
public Double TotalConsumption { get; set; }
|
||||
public Double TotalGridImport { get; set; }
|
||||
public Double TotalGridExport { get; set; }
|
||||
public Double TotalBatteryCharged { get; set; }
|
||||
public Double TotalBatteryDischarged { get; set; }
|
||||
|
||||
// Re-derived from aggregated totals
|
||||
public Double TotalEnergySaved { get; set; }
|
||||
public Double TotalSavingsCHF { get; set; }
|
||||
public Double SelfSufficiencyPercent { get; set; }
|
||||
public Double SelfConsumptionPercent { get; set; }
|
||||
public Double BatteryEfficiencyPercent { get; set; }
|
||||
public Double GridDependencyPercent { get; set; }
|
||||
|
||||
// Averaged behavioral highlights
|
||||
public Int32 AvgPeakLoadHour { get; set; }
|
||||
public Int32 AvgPeakSolarHour { get; set; }
|
||||
public Double AvgWeekdayDailyLoad { get; set; }
|
||||
public Double AvgWeekendDailyLoad { get; set; }
|
||||
|
||||
public Int32 MonthCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── DTOs for pending aggregation queries (not stored in DB) ──
|
||||
|
||||
public class PendingMonth
|
||||
{
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 Month { get; set; }
|
||||
public Int32 WeekCount { get; set; }
|
||||
}
|
||||
|
||||
public class PendingYear
|
||||
{
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 MonthCount { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
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,
|
||||
// Network = 2 removed — value reserved for legacy data
|
||||
UserAccess = 3,
|
||||
Firmware = 4,
|
||||
Configuration = 5,
|
||||
Other = 6
|
||||
}
|
||||
|
||||
public enum TicketSubCategory
|
||||
{
|
||||
General = 0, // legacy only — not offered for new tickets
|
||||
OtherLegacy = 99, // legacy catch-all — not offered for new tickets
|
||||
|
||||
// Hardware (1xx)
|
||||
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
|
||||
Metering = 104, PV = 105,
|
||||
HardwareOther = 199,
|
||||
|
||||
// Software (2xx)
|
||||
Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203,
|
||||
SoftwareOther = 299,
|
||||
|
||||
// Network (3xx) — legacy, not offered for new tickets
|
||||
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
|
||||
|
||||
// UserAccess (4xx)
|
||||
Login = 400, Permissions = 401,
|
||||
UserAccessOther = 499,
|
||||
|
||||
// Firmware (5xx)
|
||||
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502,
|
||||
ExternalEmsFirmware = 503,
|
||||
FirmwareOther = 599,
|
||||
|
||||
// Configuration (6xx)
|
||||
BMS = 600, ConfigMonitor = 601, ExternalEMS = 602,
|
||||
ConfigurationOther = 699
|
||||
}
|
||||
|
||||
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 SubCategory { get; set; } = (Int32)TicketSubCategory.Battery;
|
||||
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; }
|
||||
|
||||
public String? RootCause { get; set; }
|
||||
public String? Solution { get; set; }
|
||||
public Boolean PreFilledFromAi { get; set; }
|
||||
|
||||
public String? CustomSubCategory { get; set; }
|
||||
public String? CustomCategory { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public enum DiagnosisStatus { Pending = 0, Analyzing = 1, Completed = 2, Failed = 3 }
|
||||
public enum DiagnosisFeedback { Accepted = 0, Rejected = 1, Overridden = 2 }
|
||||
|
||||
public class TicketAiDiagnosis
|
||||
{
|
||||
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||
|
||||
[Indexed] public Int64 TicketId { get; set; }
|
||||
public Int32 Status { get; set; } = (Int32)DiagnosisStatus.Pending;
|
||||
public String? RootCause { get; set; }
|
||||
public Double? Confidence { get; set; }
|
||||
public String? RecommendedActions { get; set; } // JSON array string
|
||||
public String? SimilarTicketIds { get; set; } // comma-separated
|
||||
public Int32? Feedback { get; set; } // null = no feedback yet
|
||||
public String? OverrideText { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public enum CommentAuthorType { Human = 0, AiAgent = 1 }
|
||||
|
||||
public class TicketComment
|
||||
{
|
||||
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||
|
||||
[Indexed] public Int64 TicketId { get; set; }
|
||||
public Int32 AuthorType { get; set; } = (Int32)CommentAuthorType.Human;
|
||||
public Int64? AuthorId { get; set; }
|
||||
public String Body { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public enum TimelineEventType
|
||||
{
|
||||
Created = 0, StatusChanged = 1, Assigned = 2,
|
||||
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5,
|
||||
ResolutionAdded = 6
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ public class User : TreeNode
|
|||
public Boolean MustResetPassword { get; set; } = false;
|
||||
public String? Password { get; set; } = null!;
|
||||
public String Language { get; set; } = "en";
|
||||
public Int32? AcknowledgedTermsVersion { get; set; }
|
||||
|
||||
[Unique]
|
||||
public override String Name { get; set; } = null!;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ public class WeeklyReportResponse
|
|||
public List<DailyEnergyData> DailyData { get; set; } = new();
|
||||
public BehavioralPattern? Behavior { get; set; }
|
||||
public string AiInsight { get; set; } = "";
|
||||
|
||||
// Data availability — lets UI show which days are missing
|
||||
public int DaysAvailable { get; set; } // how many of the 7 days have data
|
||||
public int DaysExpected { get; set; } // 7 (Mon–Sun)
|
||||
public List<string> MissingDates { get; set; } = new(); // ISO dates with no data
|
||||
}
|
||||
|
||||
public class WeeklySummary
|
||||
|
|
|
|||
|
|
@ -67,6 +67,19 @@ public static partial class Db
|
|||
{
|
||||
return Insert(action);
|
||||
}
|
||||
|
||||
public static Boolean Create(WeeklyReportSummary report) => Insert(report);
|
||||
public static Boolean Create(MonthlyReportSummary report) => Insert(report);
|
||||
public static Boolean Create(YearlyReportSummary report) => Insert(report);
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,19 @@ public static partial class Db
|
|||
public static TableQuery<Error> Errors => Connection.Table<Error>();
|
||||
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
|
||||
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
|
||||
|
||||
public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>();
|
||||
public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>();
|
||||
public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>();
|
||||
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
|
||||
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()
|
||||
{
|
||||
|
|
@ -51,6 +63,18 @@ public static partial class Db
|
|||
Connection.CreateTable<Error>();
|
||||
Connection.CreateTable<Warning>();
|
||||
Connection.CreateTable<UserAction>();
|
||||
Connection.CreateTable<WeeklyReportSummary>();
|
||||
Connection.CreateTable<MonthlyReportSummary>();
|
||||
Connection.CreateTable<YearlyReportSummary>();
|
||||
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
|
||||
|
|
@ -59,6 +83,13 @@ public static partial class Db
|
|||
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
|
||||
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
|
||||
|
||||
// One-time migration: rebrand to inesco energy
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
|
||||
Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'");
|
||||
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
|
||||
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'");
|
||||
|
||||
//UpdateKeys();
|
||||
CleanupSessions().SupressAwaitWarning();
|
||||
DeleteSnapshots().SupressAwaitWarning();
|
||||
|
|
@ -88,6 +119,23 @@ public static partial class Db
|
|||
fileConnection.CreateTable<Error>();
|
||||
fileConnection.CreateTable<Warning>();
|
||||
fileConnection.CreateTable<UserAction>();
|
||||
fileConnection.CreateTable<WeeklyReportSummary>();
|
||||
fileConnection.CreateTable<MonthlyReportSummary>();
|
||||
fileConnection.CreateTable<YearlyReportSummary>();
|
||||
fileConnection.CreateTable<DailyEnergyRecord>();
|
||||
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||
fileConnection.CreateTable<AiInsightCache>();
|
||||
|
||||
// Ticket system tables
|
||||
fileConnection.CreateTable<Ticket>();
|
||||
fileConnection.CreateTable<TicketComment>();
|
||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
// Migrate new columns: set defaults for existing rows where NULL or empty
|
||||
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
|
||||
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");
|
||||
fileConnection.Execute("UPDATE Installation SET PvStringsPerInverter = '' WHERE PvStringsPerInverter IS NULL");
|
||||
|
||||
return fileConnection;
|
||||
//return CopyDbToMemory(fileConnection);
|
||||
|
|
|
|||
|
|
@ -102,10 +102,39 @@ public static partial class Db
|
|||
InstallationAccess.Delete(i => i.InstallationId == installation.Id);
|
||||
if (installation.Product == (int)ProductType.Salimax)
|
||||
{
|
||||
//For Salimax, delete the OrderNumber2Installation entries associated with this installation id.
|
||||
OrderNumber2Installation.Delete(i => i.InstallationId == installation.Id);
|
||||
}
|
||||
|
||||
|
||||
// Clean up AI insight cache entries linked to this installation's reports
|
||||
var weeklyIds = WeeklyReports .Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList();
|
||||
var monthlyIds = MonthlyReports.Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList();
|
||||
var yearlyIds = YearlyReports .Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList();
|
||||
|
||||
foreach (var id in weeklyIds) AiInsightCaches.Delete(c => c.ReportType == "weekly" && c.ReportId == id);
|
||||
foreach (var id in monthlyIds) AiInsightCaches.Delete(c => c.ReportType == "monthly" && c.ReportId == id);
|
||||
foreach (var id in yearlyIds) AiInsightCaches.Delete(c => c.ReportType == "yearly" && c.ReportId == id);
|
||||
|
||||
// Clean up energy records, report summaries, errors, warnings, and user actions
|
||||
DailyRecords .Delete(r => r.InstallationId == installation.Id);
|
||||
HourlyRecords .Delete(r => r.InstallationId == installation.Id);
|
||||
WeeklyReports .Delete(r => r.InstallationId == installation.Id);
|
||||
MonthlyReports.Delete(r => r.InstallationId == installation.Id);
|
||||
YearlyReports .Delete(r => r.InstallationId == installation.Id);
|
||||
Errors .Delete(e => e.InstallationId == installation.Id);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,4 +170,121 @@ public static partial class Db
|
|||
{
|
||||
OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber);
|
||||
}
|
||||
|
||||
public static void DeleteWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month)
|
||||
{
|
||||
var monthStart = $"{year:D4}-{month:D2}-01";
|
||||
var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01";
|
||||
|
||||
// SQLite-net doesn't support string comparison in Delete lambda,
|
||||
// so fetch matching IDs first, then delete by ID.
|
||||
var ids = WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in ids)
|
||||
WeeklyReports.Delete(r => r.Id == id);
|
||||
|
||||
if (ids.Count > 0) Backup();
|
||||
}
|
||||
|
||||
public static void DeleteMonthlyReportsForYear(Int64 installationId, Int32 year)
|
||||
{
|
||||
MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
|
||||
Backup();
|
||||
}
|
||||
|
||||
public static void DeleteMonthlyReport(Int64 installationId, Int32 year, Int32 month)
|
||||
{
|
||||
var count = MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year && r.Month == month);
|
||||
if (count > 0) Backup();
|
||||
}
|
||||
|
||||
public static void DeleteYearlyReport(Int64 installationId, Int32 year)
|
||||
{
|
||||
var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
|
||||
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
|
||||
/// date fields (SQLite-net doesn't support string comparisons in Delete lambdas).
|
||||
/// </summary>
|
||||
public static void CleanupOldData()
|
||||
{
|
||||
var cutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddYears(-1).ToString("yyyy-MM-dd");
|
||||
var prevYear = DateTime.UtcNow.Year - 1;
|
||||
|
||||
// Daily records older than 1 year
|
||||
var oldDailyIds = DailyRecords
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldDailyIds)
|
||||
DailyRecords.Delete(r => r.Id == id);
|
||||
|
||||
// Hourly records older than 3 months (sufficient for pattern detection, 24x more rows than daily)
|
||||
var hourlyCutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddMonths(-3).ToString("yyyy-MM-dd");
|
||||
var oldHourlyIds = HourlyRecords
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, hourlyCutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldHourlyIds)
|
||||
HourlyRecords.Delete(r => r.Id == id);
|
||||
|
||||
// Weekly summaries older than 1 year
|
||||
var oldWeeklyIds = WeeklyReports
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldWeeklyIds)
|
||||
WeeklyReports.Delete(r => r.Id == id);
|
||||
|
||||
// Monthly summaries older than 1 year
|
||||
var oldMonthlyIds = MonthlyReports
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldMonthlyIds)
|
||||
MonthlyReports.Delete(r => r.Id == id);
|
||||
|
||||
// Yearly summaries — keep current and previous year only
|
||||
YearlyReports.Delete(r => r.Year < prevYear);
|
||||
|
||||
// AI insight cache entries older than 1 year
|
||||
var oldCacheIds = AiInsightCaches
|
||||
.ToList()
|
||||
.Where(c => String.Compare(c.CreatedAt, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldCacheIds)
|
||||
AiInsightCaches.Delete(c => c.Id == id);
|
||||
|
||||
Backup();
|
||||
Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff}).");
|
||||
}
|
||||
}
|
||||
|
|
@ -56,4 +56,153 @@ public static partial class Db
|
|||
|
||||
return session;
|
||||
}
|
||||
|
||||
// ── Report Queries ────────────────────────────────────────────────
|
||||
|
||||
public static List<WeeklyReportSummary> GetWeeklyReports(Int64 installationId)
|
||||
=> WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.OrderByDescending(r => r.PeriodStart)
|
||||
.ToList();
|
||||
|
||||
public static List<WeeklyReportSummary> GetWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month)
|
||||
{
|
||||
var monthStart = $"{year:D4}-{month:D2}-01";
|
||||
var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01";
|
||||
return WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0)
|
||||
.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)
|
||||
.OrderByDescending(r => r.Year)
|
||||
.ThenByDescending(r => r.Month)
|
||||
.ToList();
|
||||
|
||||
public static List<MonthlyReportSummary> GetMonthlyReportsForYear(Int64 installationId, Int32 year)
|
||||
=> MonthlyReports
|
||||
.Where(r => r.InstallationId == installationId && r.Year == year)
|
||||
.ToList();
|
||||
|
||||
public static List<YearlyReportSummary> GetYearlyReports(Int64 installationId)
|
||||
=> YearlyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.OrderByDescending(r => r.Year)
|
||||
.ToList();
|
||||
|
||||
// ── DailyEnergyRecord Queries ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns daily records for an installation within [from, to] inclusive, ordered by date.
|
||||
/// </summary>
|
||||
public static List<DailyEnergyRecord> GetDailyRecords(Int64 installationId, DateOnly from, DateOnly to)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a daily record already exists for this installation+date (idempotency check).
|
||||
/// </summary>
|
||||
public static Boolean DailyRecordExists(Int64 installationId, String date)
|
||||
=> DailyRecords
|
||||
.Any(r => r.InstallationId == installationId && r.Date == date);
|
||||
|
||||
// ── HourlyEnergyRecord Queries ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns hourly records for an installation within [from, to] inclusive, ordered by date+hour.
|
||||
/// </summary>
|
||||
public static List<HourlyEnergyRecord> GetHourlyRecords(Int64 installationId, DateOnly from, DateOnly to)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an hourly record already exists for this installation+dateHour (idempotency check).
|
||||
/// </summary>
|
||||
public static Boolean HourlyRecordExists(Int64 installationId, String dateHour)
|
||||
=> HourlyRecords
|
||||
.Any(r => r.InstallationId == installationId && r.DateHour == dateHour);
|
||||
|
||||
// ── AiInsightCache Queries ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached AI insight text for (reportType, reportId, language), or null on miss.
|
||||
/// </summary>
|
||||
public static String? GetCachedInsight(String reportType, Int64 reportId, String language)
|
||||
=> AiInsightCaches
|
||||
.FirstOrDefault(c => c.ReportType == reportType
|
||||
&& 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();
|
||||
|
||||
public static List<String> GetCustomSubCategoriesForCategory(Int32 category)
|
||||
=> Tickets
|
||||
.Where(t => t.Category == category && t.CustomSubCategory != null)
|
||||
.Select(t => t.CustomSubCategory!)
|
||||
.Distinct()
|
||||
.OrderBy(s => s)
|
||||
.ToList();
|
||||
|
||||
public static List<String> GetCustomCategories()
|
||||
=> Tickets
|
||||
.Where(t => t.CustomCategory != null)
|
||||
.Select(t => t.CustomCategory!)
|
||||
.Distinct()
|
||||
.OrderBy(s => s)
|
||||
.ToList();
|
||||
}
|
||||
|
|
@ -49,11 +49,27 @@ 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);
|
||||
}
|
||||
|
|
@ -1,94 +1,157 @@
|
|||
using System.Diagnostics;
|
||||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using InnovEnergy.Lib.Utils;
|
||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||
using InnovEnergy.Lib.S3Utils;
|
||||
using InnovEnergy.Lib.S3Utils.DataTypes;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DeleteOldData;
|
||||
|
||||
public class DeleteOldDataFromS3
|
||||
public static class DeleteOldDataFromS3
|
||||
{
|
||||
private static Timer? _cleanupTimer;
|
||||
|
||||
public static void DeleteFrom(Installation installation, int timestamps_to_delete)
|
||||
public static void StartScheduler()
|
||||
{
|
||||
|
||||
string configPath = "/home/ubuntu/.s3cfg";
|
||||
string bucketPath = installation.Product ==(int)ProductType.Salidomo ? $"s3://{installation.S3BucketId}-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/{timestamps_to_delete}*" : $"s3://{installation.S3BucketId}-3e5b3069-214a-43ee-8d85-57d72000c19d/{timestamps_to_delete}*" ;
|
||||
|
||||
//Console.WriteLine($"Deleting old data from {bucketPath}");
|
||||
var now = DateTime.UtcNow;
|
||||
var next = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc);
|
||||
if (next <= now) next = next.AddDays(1);
|
||||
|
||||
Console.WriteLine("Deleting data for timestamp prefix: " + timestamps_to_delete);
|
||||
|
||||
try
|
||||
{
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
_cleanupTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
FileName = "s3cmd",
|
||||
Arguments = $"--config {configPath} rm {bucketPath}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using Process process = new Process { StartInfo = startInfo };
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
Console.WriteLine("[s3cmd] " + e.Data);
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
Console.WriteLine("[s3cmd-ERR] " + e.Data);
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
process.WaitForExit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Exception occurred during deletion: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task DeleteOldData()
|
||||
{
|
||||
while (true){
|
||||
var installations = Db.Installations.ToList();
|
||||
foreach (var installation in installations){
|
||||
Console.WriteLine("DELETE S3 DATA FOR INSTALLATION "+installation.Name);
|
||||
long oneYearAgoTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
|
||||
|
||||
Console.WriteLine("delete data before "+oneYearAgoTimestamp);
|
||||
for (int lastDigit=4;lastDigit>=0; lastDigit--)
|
||||
{
|
||||
int timestamps_to_delete = int.Parse(oneYearAgoTimestamp.ToString().Substring(0, lastDigit+1));
|
||||
timestamps_to_delete--;
|
||||
Console.WriteLine(timestamps_to_delete);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (timestamps_to_delete % 10 == 0)
|
||||
{
|
||||
Console.WriteLine("delete " + timestamps_to_delete + "*");
|
||||
DeleteFrom(installation,timestamps_to_delete);
|
||||
break;
|
||||
}
|
||||
Console.WriteLine("delete " + timestamps_to_delete + "*");
|
||||
DeleteFrom(installation,timestamps_to_delete);
|
||||
timestamps_to_delete--;
|
||||
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
CleanupAllInstallations().GetAwaiter().GetResult();
|
||||
}
|
||||
Console.WriteLine("FINISHED DELETING S3 DATA FOR ALL INSTALLATIONS\n");
|
||||
|
||||
await Task.Delay(TimeSpan.FromDays(1));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[S3Cleanup] Scheduler error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
null,
|
||||
next - now,
|
||||
TimeSpan.FromDays(1)
|
||||
);
|
||||
|
||||
Console.WriteLine($"[S3Cleanup] Scheduled daily at 03:00 UTC, first run in {(next - now).TotalHours:F1}h");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static async Task CleanupAllInstallations()
|
||||
{
|
||||
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
|
||||
var cutoffKey = cutoffTimestamp.ToString();
|
||||
var installations = Db.Installations.ToList();
|
||||
|
||||
Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}");
|
||||
|
||||
foreach (var installation in installations)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s3Region = new S3Region(
|
||||
$"https://{installation.S3Region}.{installation.S3Provider}",
|
||||
ExoCmd.S3Credentials
|
||||
);
|
||||
var bucket = s3Region.Bucket(installation.BucketName());
|
||||
|
||||
Console.WriteLine($"[S3Cleanup] Processing {installation.Name} (bucket: {bucket.Name})");
|
||||
var deleted = await DeleteObjectsBefore(bucket, cutoffKey);
|
||||
Console.WriteLine($"[S3Cleanup] {installation.Name}: deleted {deleted} objects");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[S3Cleanup] Failed for {installation.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("[S3Cleanup] Finished cleanup for all installations");
|
||||
}
|
||||
|
||||
public static async Task<string> DryRun(long? installationId = null)
|
||||
{
|
||||
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
|
||||
var cutoffKey = cutoffTimestamp.ToString();
|
||||
var allInstallations = Db.Installations.ToList();
|
||||
var installations = installationId.HasValue
|
||||
? allInstallations.Where(i => i.Id == installationId.Value).ToList()
|
||||
: allInstallations;
|
||||
var results = new List<string>();
|
||||
|
||||
results.Add($"Cutoff: {cutoffKey} ({DateTimeOffset.FromUnixTimeSeconds(cutoffTimestamp):yyyy-MM-dd HH:mm:ss} UTC)");
|
||||
results.Add($"Installations: {installations.Count} (of {allInstallations.Count} total)");
|
||||
results.Add("");
|
||||
|
||||
foreach (var installation in installations)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s3Region = new S3Region(
|
||||
$"https://{installation.S3Region}.{installation.S3Provider}",
|
||||
ExoCmd.S3Credentials
|
||||
);
|
||||
var bucket = s3Region.Bucket(installation.BucketName());
|
||||
|
||||
var sampleKeys = new List<string>();
|
||||
var hasOldData = false;
|
||||
|
||||
await foreach (var obj in bucket.ListObjects())
|
||||
{
|
||||
if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0)
|
||||
break;
|
||||
|
||||
hasOldData = true;
|
||||
if (sampleKeys.Count < 5)
|
||||
sampleKeys.Add(obj.Path);
|
||||
else
|
||||
break; // only need a sample, not full count
|
||||
}
|
||||
|
||||
results.Add($"{installation.Name} (bucket: {bucket.Name})");
|
||||
results.Add($" Has old data: {(hasOldData ? "YES" : "NO")}");
|
||||
if (sampleKeys.Count > 0)
|
||||
results.Add($" Sample keys: {string.Join(", ", sampleKeys)}");
|
||||
results.Add("");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add($"{installation.Name}: ERROR - {ex.Message}");
|
||||
results.Add("");
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n", results);
|
||||
}
|
||||
|
||||
private static async Task<int> DeleteObjectsBefore(S3Bucket bucket, string cutoffKey)
|
||||
{
|
||||
var totalDeleted = 0;
|
||||
var keysToDelete = new List<string>();
|
||||
|
||||
await foreach (var obj in bucket.ListObjects())
|
||||
{
|
||||
if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0)
|
||||
break;
|
||||
|
||||
keysToDelete.Add(obj.Path);
|
||||
|
||||
if (keysToDelete.Count >= 1000)
|
||||
{
|
||||
if (await bucket.DeleteObjects(keysToDelete))
|
||||
totalDeleted += keysToDelete.Count;
|
||||
else
|
||||
Console.Error.WriteLine($"[S3Cleanup] Failed to delete batch of {keysToDelete.Count} objects from {bucket.Name}");
|
||||
|
||||
keysToDelete.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (keysToDelete.Count > 0)
|
||||
{
|
||||
if (await bucket.DeleteObjects(keysToDelete))
|
||||
totalDeleted += keysToDelete.Count;
|
||||
else
|
||||
Console.Error.WriteLine($"[S3Cleanup] Failed to delete batch of {keysToDelete.Count} objects from {bucket.Name}");
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
"SmtpUsername" : "no-reply@inesco.ch",
|
||||
"SmtpPassword" : "1ci4vi%+bfccIp",
|
||||
"SmtpPort" : 587,
|
||||
"SenderName" : "Inesco Energy",
|
||||
"SenderName" : "inesco energy",
|
||||
"SenderAddress" : "no-reply@inesco.ch"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ public static class Program
|
|||
Db.Init();
|
||||
LoadEnvFile();
|
||||
DiagnosticService.Initialize();
|
||||
TicketDiagnosticService.Initialize();
|
||||
NetworkProviderService.Initialize();
|
||||
AlarmReviewService.StartDailyScheduler();
|
||||
DailyIngestionService.StartScheduler();
|
||||
ReportAggregationService.StartScheduler();
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
RabbitMqManager.InitializeEnvironment();
|
||||
|
|
@ -36,7 +40,7 @@ public static class Program
|
|||
WebsocketManager.MonitorInstallationTable().SupressAwaitWarning();
|
||||
|
||||
|
||||
// Task.Run(() => DeleteOldDataFromS3.DeleteOldData());
|
||||
DeleteOldDataFromS3.StartScheduler();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddProblemDetails(setup =>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ public class Session : Relation<String, Int64>
|
|||
public Boolean AccessToSalidomo { get; set; } = false;
|
||||
public Boolean AccessToSodistoreMax { get; set; } = false;
|
||||
public Boolean AccessToSodioHome { get; set; } = false;
|
||||
public Boolean AccessToSodistoreGrid { get; set; } = false;
|
||||
public Boolean AccessToSodistorePro { get; set; } = false;
|
||||
[Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ;
|
||||
|
||||
// Private backing field
|
||||
|
|
@ -49,7 +51,9 @@ public class Session : Relation<String, Int64>
|
|||
AccessToSalidomo = user.AccessibleInstallations(product: (int)ProductType.Salidomo).ToList().Count > 0;
|
||||
AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0;
|
||||
AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0;
|
||||
|
||||
AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0;
|
||||
AccessToSodistorePro = user.AccessibleInstallations(product: (int)ProductType.SodistorePro).ToList().Count > 0;
|
||||
|
||||
Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count);
|
||||
Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count);
|
||||
Console.WriteLine("sodio" + user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count);
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@
|
|||
"PvAccessMethodErrorAlarm": "PV-Zugriffsfehler",
|
||||
"ReservedAlarms4": "Reservierter Alarm 4",
|
||||
"ReservedAlarms5": "Reservierter Alarm 5",
|
||||
"ReverseMeterConnection": "Zähler falsch angeschlossen",
|
||||
"ReverseMeterConnection": "Zähleranschluss vertauscht",
|
||||
"InverterSealPulse": "Wechselrichter-Leistungsbegrenzung",
|
||||
"AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung",
|
||||
"AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz",
|
||||
|
|
@ -204,10 +204,10 @@
|
|||
"BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen",
|
||||
"EpoFault": "EPO-Fehler (Notaus)",
|
||||
"MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler",
|
||||
"BmsCommunicationFailure": "BMS-Kommunikationsfehler",
|
||||
"BmsCommunicationFailure": "BMS-Kommunikation ausgefallen",
|
||||
"BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler",
|
||||
"BatteryVoltageLow": "Batteriespannung zu niedrig",
|
||||
"BatteryVoltageHigh": "Batteriespannung zu hoch",
|
||||
"BatteryVoltageLow": "Batteriespannung niedrig",
|
||||
"BatteryVoltageHigh": "Batteriespannung hoch",
|
||||
"BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich",
|
||||
"BatteryReversed": "Batterie verkehrt herum",
|
||||
"BatteryOpenCircuit": "Batteriekreis offen",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"alarm_AbnormalOutputVoltage": "Tension de sortie anormale",
|
||||
"alarm_AbnormalOutputFrequency": "Fréquence de sortie anormale",
|
||||
"alarm_AbnormalNullLine": "Ligne neutre anormale",
|
||||
"alarm_AbnormalOffGridOutputVoltage": "Tension de sortie hors réseau anormale",
|
||||
"alarm_AbnormalOffGridOutputVoltage": "Tension de sortie backup anormale",
|
||||
"alarm_ExcessivelyHighAmbientTemperature": "Température ambiante trop élevée",
|
||||
"alarm_ExcessiveRadiatorTemperature": "Température excessive du radiateur",
|
||||
"alarm_PcbOvertemperature": "Température excessive PCB",
|
||||
|
|
@ -183,7 +183,7 @@
|
|||
"alarm_ExportLimitationFailSafe": "Sécurité limite d'exportation",
|
||||
"alarm_DcBiasAbnormal": "Biais DC anormal",
|
||||
"alarm_HighDcComponentOutputCurrent": "Composante DC élevée courant de sortie",
|
||||
"alarm_BusVoltageSamplingAbnormal": "Tension d'alimentation anormale",
|
||||
"alarm_BusVoltageSamplingAbnormal": "Échantillonnage tension bus anormal",
|
||||
"alarm_RelayFault": "Défaillance du relais",
|
||||
"alarm_BusVoltageAbnormal": "Tension d'alimentation anormale",
|
||||
"alarm_InternalCommunicationFailure": "Échec de communication interne",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
"alarm_LithiumBattery1Full": "Batteria Litio 1 Piena",
|
||||
"alarm_LithiumBattery1DischargeEnd": "Fine Scarica Batteria Litio 1",
|
||||
"alarm_LithiumBattery2Full": "Batteria Litio 2 Piena",
|
||||
"alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria 2",
|
||||
"alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria Litio 2",
|
||||
"alarm_LeadBatteryTemperatureAbnormality": "Temperatura Batteria Anomala",
|
||||
"alarm_BatteryAccessMethodError": "Errore Metodo Accesso Batteria",
|
||||
"alarm_Pv1NotAccessed": "PV1 Non Rilevato",
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
"alarm_InverterSealPulse": "Impulso Sigillo Inverter",
|
||||
"alarm_AbnormalDieselGeneratorVoltage": "Tensione Generatore Diesel Anomala",
|
||||
"alarm_AbnormalDieselGeneratorFrequency": "Frequenza Generatore Diesel Anomala",
|
||||
"alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase invertita",
|
||||
"alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase generatore invertita",
|
||||
"alarm_DieselGeneratorVoltageOutOfPhase": "Fase del generatore errata",
|
||||
"alarm_GeneratorOverload": "Sovraccarico del generatore",
|
||||
"alarm_StringFault": "Guasto alla stringa",
|
||||
|
|
@ -208,7 +208,7 @@
|
|||
"alarm_BmsChargeDischargeFailure": "Guasto Carica/Scarica BMS",
|
||||
"alarm_BatteryVoltageLow": "Tensione Batteria Bassa",
|
||||
"alarm_BatteryVoltageHigh": "Tensione Batteria Alta",
|
||||
"alarm_BatteryTemperatureAbnormal": "Temperatura batteria anomala",
|
||||
"alarm_BatteryTemperatureAbnormal": "Temperatura batteria fuori norma",
|
||||
"alarm_BatteryReversed": "Batteria invertita",
|
||||
"alarm_BatteryOpenCircuit": "Circuiti aperti batteria",
|
||||
"alarm_BatteryOverloadProtection": "Protezione sovraccarico batteria",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||
using InnovEnergy.Lib.S3Utils;
|
||||
using InnovEnergy.Lib.S3Utils.DataTypes;
|
||||
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses NDJSON aggregated data files generated by SodistoreHome devices.
|
||||
/// Each file (DDMMYYYY.json) contains one JSON object per line:
|
||||
/// - Type "Hourly": per-hour kWh values (already computed, no diffing needed)
|
||||
/// - Type "Daily": daily totals
|
||||
/// </summary>
|
||||
public static class AggregatedJsonParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public static List<DailyEnergyData> ParseDaily(String ndjsonContent)
|
||||
{
|
||||
var dailyByDate = new SortedDictionary<String, DailyEnergyData>();
|
||||
|
||||
foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!line.Contains("\"Type\":\"Daily\""))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<DailyJsonDto>(line, JsonOpts);
|
||||
if (raw is null) continue;
|
||||
|
||||
var date = raw.Timestamp.ToString("yyyy-MM-dd");
|
||||
|
||||
dailyByDate[date] = new DailyEnergyData
|
||||
{
|
||||
Date = date,
|
||||
PvProduction = Math.Round(raw.DailySelfGeneratedElectricity, 4),
|
||||
GridImport = Math.Round(raw.DailyElectricityPurchased, 4),
|
||||
GridExport = Math.Round(raw.DailyElectricityFed, 4),
|
||||
BatteryCharged = Math.Round(raw.BatteryDailyChargeEnergy, 4),
|
||||
BatteryDischarged = Math.Round(raw.BatteryDailyDischargeEnergy, 4),
|
||||
LoadConsumption = Math.Round(raw.DailyLoadPowerConsumption, 4),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AggregatedJsonParser] Skipping daily line: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[AggregatedJsonParser] Parsed {dailyByDate.Count} daily record(s)");
|
||||
return dailyByDate.Values.ToList();
|
||||
}
|
||||
|
||||
public static List<HourlyEnergyData> ParseHourly(String ndjsonContent)
|
||||
{
|
||||
var result = new List<HourlyEnergyData>();
|
||||
|
||||
foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!line.Contains("\"Type\":\"Hourly\""))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<HourlyJsonDto>(line, JsonOpts);
|
||||
if (raw is null) continue;
|
||||
|
||||
var dt = new DateTime(
|
||||
raw.Timestamp.Year, raw.Timestamp.Month, raw.Timestamp.Day,
|
||||
raw.Timestamp.Hour, 0, 0);
|
||||
|
||||
result.Add(new HourlyEnergyData
|
||||
{
|
||||
DateTime = dt,
|
||||
Hour = dt.Hour,
|
||||
DayOfWeek = dt.DayOfWeek.ToString(),
|
||||
IsWeekend = dt.DayOfWeek is System.DayOfWeek.Saturday or System.DayOfWeek.Sunday,
|
||||
PvKwh = Math.Round(raw.SelfGeneratedElectricity, 4),
|
||||
GridImportKwh = Math.Round(raw.ElectricityPurchased, 4),
|
||||
BatteryChargedKwh = Math.Round(raw.BatteryChargeEnergy, 4),
|
||||
BatteryDischargedKwh = Math.Round(raw.BatteryDischargeEnergy, 4),
|
||||
LoadKwh = Math.Round(raw.LoadPowerConsumption, 4),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AggregatedJsonParser] Skipping hourly line: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[AggregatedJsonParser] Parsed {result.Count} hourly record(s)");
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts ISO date "yyyy-MM-dd" to device filename format "ddMMyyyy".
|
||||
/// </summary>
|
||||
public static String ToJsonFileName(String isoDate)
|
||||
{
|
||||
var d = DateOnly.ParseExact(isoDate, "yyyy-MM-dd");
|
||||
return d.ToString("ddMMyyyy") + ".json";
|
||||
}
|
||||
|
||||
public static String ToJsonFileName(DateOnly date) => date.ToString("ddMMyyyy") + ".json";
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read an aggregated JSON file from the installation's S3 bucket.
|
||||
/// S3 key: DDMMYYYY.json (directly in bucket root).
|
||||
/// Returns file content or null if not found / error.
|
||||
/// </summary>
|
||||
public static async Task<String?> TryReadFromS3(Installation installation, String isoDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = ToJsonFileName(isoDate);
|
||||
var region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", ExoCmd.S3Credentials!);
|
||||
var bucket = region.Bucket(installation.BucketName());
|
||||
var s3Url = bucket.Path(fileName);
|
||||
|
||||
return await s3Url.GetObjectAsString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AggregatedJsonParser] S3 read failed for {isoDate}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON DTOs ---
|
||||
|
||||
private sealed class HourlyJsonDto
|
||||
{
|
||||
public String Type { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; }
|
||||
public Double SelfGeneratedElectricity { get; set; }
|
||||
public Double ElectricityPurchased { get; set; }
|
||||
public Double ElectricityFed { get; set; }
|
||||
public Double BatteryChargeEnergy { get; set; }
|
||||
public Double BatteryDischargeEnergy { get; set; }
|
||||
public Double LoadPowerConsumption { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DailyJsonDto
|
||||
{
|
||||
public String Type { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; }
|
||||
public Double DailySelfGeneratedElectricity { get; set; }
|
||||
public Double DailyElectricityPurchased { get; set; }
|
||||
public Double DailyElectricityFed { get; set; }
|
||||
public Double BatteryDailyChargeEnergy { get; set; }
|
||||
public Double BatteryDailyDischargeEnergy { get; set; }
|
||||
public Double DailyLoadPowerConsumption { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1223,7 +1223,7 @@ textarea:focus{outline:none;border-color:#3498db}
|
|||
<div id="app"></div>
|
||||
<div class="nav" id="nav"></div>
|
||||
<div class="thankyou">Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</div>
|
||||
<div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco Energy Monitor</div>
|
||||
<div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco energy Monitor</div>
|
||||
<script>
|
||||
var ALARMS = %%ALARMS_JSON%%;
|
||||
var SUBMIT_URL = "%%SUBMIT_URL%%";
|
||||
|
|
@ -1473,7 +1473,7 @@ render();
|
|||
<p style="margin-bottom:0;font-size:13px;color:#555">Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</p>
|
||||
</td></tr>
|
||||
<tr><td style="background:#ecf0f1;padding:12px 28px;text-align:center;font-size:11px;color:#888;border-top:1px solid #ddd">
|
||||
inesco Energy Monitor
|
||||
inesco energy Monitor
|
||||
</td></tr>
|
||||
</table></td></tr></table></body></html>
|
||||
""";
|
||||
|
|
@ -1545,7 +1545,7 @@ render();
|
|||
<p>Hallo <strong>{name}</strong>,</p>
|
||||
<p style="margin-top:12px">Kurze Erinnerung — die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p>
|
||||
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen →</a></p>
|
||||
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
|
||||
<p style="font-size:11px;color:#bbb">inesco energy Monitor</p>
|
||||
</body></html>
|
||||
""";
|
||||
await SendEmailAsync(email, subject, html);
|
||||
|
|
@ -1645,7 +1645,7 @@ render();
|
|||
<table style="border-collapse:collapse;width:100%">
|
||||
{beforeAfterRows}
|
||||
</table>
|
||||
<p style="margin-top:18px;font-size:11px;color:#bbb">inesco Energy Monitor</p>
|
||||
<p style="margin-top:18px;font-size:11px;color:#bbb">inesco energy Monitor</p>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
|
||||
/// Data source priority: JSON (S3) → xlsx fallback.
|
||||
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
|
||||
/// IngestDailyData API endpoint.
|
||||
/// </summary>
|
||||
public static class DailyIngestionService
|
||||
{
|
||||
private static readonly String TmpReportDir =
|
||||
Environment.CurrentDirectory + "/tmp_report/";
|
||||
|
||||
private static Timer? _dailyTimer;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the daily scheduler. Call once on app startup.
|
||||
/// Ingests xlsx data at 01:00 UTC every day.
|
||||
/// </summary>
|
||||
public static void StartScheduler()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var next = now.Date.AddDays(1).AddHours(1); // 01:00 UTC tomorrow
|
||||
|
||||
_dailyTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
IngestAllInstallationsAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Scheduler error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
null, next - now, TimeSpan.FromDays(1));
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Scheduler started. Next run: {next:yyyy-MM-dd HH:mm} UTC");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests xlsx data for all SodioHome installations. Safe to call manually.
|
||||
/// </summary>
|
||||
public static async Task IngestAllInstallationsAsync()
|
||||
{
|
||||
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
||||
.ToList();
|
||||
|
||||
foreach (var installation in installations)
|
||||
{
|
||||
try
|
||||
{
|
||||
await IngestInstallationAsync(installation.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed for installation {installation.Id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Ingestion complete.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests data for one installation. Tries JSON (S3) and xlsx.
|
||||
/// Both sources are tried — idempotency checks prevent duplicates.
|
||||
/// JSON provides recent data; xlsx provides historical data.
|
||||
/// </summary>
|
||||
public static async Task IngestInstallationAsync(Int64 installationId)
|
||||
{
|
||||
await TryIngestFromJson(installationId);
|
||||
IngestFromXlsx(installationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests S3 JSON data for a specific date range. Used by report services
|
||||
/// as a fallback when SQLite has no records for the requested period.
|
||||
/// Idempotent — skips dates already in DB.
|
||||
/// </summary>
|
||||
public static async Task IngestDateRangeAsync(Int64 installationId, DateOnly fromDate, DateOnly toDate)
|
||||
{
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null) return;
|
||||
|
||||
var newDaily = 0;
|
||||
var newHourly = 0;
|
||||
|
||||
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
|
||||
{
|
||||
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} (S3 date-range {fromDate:yyyy-MM-dd}–{toDate:yyyy-MM-dd}): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
||||
}
|
||||
|
||||
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
|
||||
{
|
||||
var newDaily = 0;
|
||||
var newHourly = 0;
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null) return false;
|
||||
|
||||
// Try S3 for recent days (yesterday + today), skip if already in DB
|
||||
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))
|
||||
{
|
||||
Console.WriteLine($"[DailyIngestion] tmp_report directory not found, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var xlsxFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx");
|
||||
if (xlsxFiles.Length == 0)
|
||||
{
|
||||
Console.WriteLine($"[DailyIngestion] No xlsx found for installation {installationId}, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newDailyCount = 0;
|
||||
var newHourlyCount = 0;
|
||||
var totalParsed = 0;
|
||||
|
||||
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
|
||||
{
|
||||
List<DailyEnergyData> days;
|
||||
try { days = ExcelDataParser.Parse(xlsxPath); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
totalParsed += days.Count;
|
||||
|
||||
foreach (var day in days)
|
||||
{
|
||||
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"),
|
||||
});
|
||||
newDailyCount++;
|
||||
}
|
||||
|
||||
List<HourlyEnergyData> hours;
|
||||
try { hours = ExcelDataParser.ParseHourly(xlsxPath); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var hour in hours)
|
||||
{
|
||||
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"),
|
||||
});
|
||||
newHourlyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Installation {installationId} (xlsx): {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using Flurl.Http;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and caches the list of Swiss electricity network providers (Netzbetreiber)
|
||||
/// from the ELCOM/LINDAS SPARQL endpoint. Refreshes every 24 hours.
|
||||
/// </summary>
|
||||
public static class NetworkProviderService
|
||||
{
|
||||
private static IReadOnlyList<string> _providers = Array.Empty<string>();
|
||||
private static Timer? _refreshTimer;
|
||||
|
||||
private const string SparqlEndpoint = "https://ld.admin.ch/query";
|
||||
|
||||
private const string SparqlQuery = @"
|
||||
PREFIX schema: <http://schema.org/>
|
||||
SELECT DISTINCT ?name
|
||||
FROM <https://lindas.admin.ch/elcom/electricityprice>
|
||||
WHERE {
|
||||
?operator a schema:Organization ;
|
||||
schema:name ?name .
|
||||
}
|
||||
ORDER BY ?name";
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
// Fire-and-forget initial load
|
||||
Task.Run(RefreshAsync);
|
||||
|
||||
// Refresh every 24 hours
|
||||
_refreshTimer = new Timer(
|
||||
_ => Task.Run(RefreshAsync),
|
||||
null,
|
||||
TimeSpan.FromHours(24),
|
||||
TimeSpan.FromHours(24)
|
||||
);
|
||||
|
||||
Console.WriteLine("[NetworkProviderService] initialised.");
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> GetProviders() => _providers;
|
||||
|
||||
private static async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await SparqlEndpoint
|
||||
.WithHeader("Accept", "application/sparql-results+json")
|
||||
.PostUrlEncodedAsync(new { query = SparqlQuery });
|
||||
|
||||
var json = await response.GetStringAsync();
|
||||
var parsed = JObject.Parse(json);
|
||||
|
||||
var names = parsed["results"]?["bindings"]?
|
||||
.Select(b => b["name"]?["value"]?.ToString())
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.Distinct()
|
||||
.OrderBy(n => n)
|
||||
.ToList();
|
||||
|
||||
if (names is { Count: > 0 })
|
||||
{
|
||||
_providers = names!;
|
||||
Console.WriteLine($"[NetworkProviderService] Loaded {names.Count} providers from ELCOM.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[NetworkProviderService] SPARQL query returned no results.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[NetworkProviderService] Failed to fetch providers: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,802 @@
|
|||
using Flurl.Http;
|
||||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
public static class ReportAggregationService
|
||||
{
|
||||
private static Timer? _monthEndTimer;
|
||||
private static Timer? _yearEndTimer;
|
||||
private static Timer? _weeklyReportTimer;
|
||||
|
||||
private const Double ElectricityPriceCHF = 0.39;
|
||||
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||
|
||||
// ── Scheduler ─────────────────────────────────────────────────────
|
||||
|
||||
public static void StartScheduler()
|
||||
{
|
||||
ScheduleWeeklyReportJob();
|
||||
ScheduleMonthEndJob();
|
||||
ScheduleYearEndJob();
|
||||
Console.WriteLine("[ReportAggregation] Scheduler started.");
|
||||
}
|
||||
|
||||
private static void ScheduleWeeklyReportJob()
|
||||
{
|
||||
// Run every Monday at 03:00 UTC — after DailyIngestionService (01:00 UTC)
|
||||
var now = DateTime.UtcNow;
|
||||
var daysUntil = ((Int32)DayOfWeek.Monday - (Int32)now.DayOfWeek + 7) % 7;
|
||||
var nextMon = now.Date.AddDays(daysUntil == 0 && now.Hour >= 3 ? 7 : daysUntil).AddHours(3);
|
||||
|
||||
_weeklyReportTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.UtcNow.DayOfWeek == DayOfWeek.Monday)
|
||||
RunWeeklyReportGeneration().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Weekly report error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
null, nextMon - now, TimeSpan.FromDays(7));
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Weekly report scheduled (Monday 03:00 UTC). Next run: {nextMon:yyyy-MM-dd HH:mm} UTC");
|
||||
}
|
||||
|
||||
private static void ScheduleMonthEndJob()
|
||||
{
|
||||
// Run daily at 04:00 UTC, but only act on the 1st of the month
|
||||
var now = DateTime.UtcNow;
|
||||
var next = now.Date.AddHours(4);
|
||||
if (now >= next) next = next.AddDays(1);
|
||||
|
||||
_monthEndTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.UtcNow.Day == 1)
|
||||
RunMonthEndAggregation().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Month-end error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
null, next - now, TimeSpan.FromDays(1));
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 04:00 UTC, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm} UTC");
|
||||
}
|
||||
|
||||
private static void ScheduleYearEndJob()
|
||||
{
|
||||
// Run daily at 05:00 UTC, but only act on Jan 2nd
|
||||
var now = DateTime.UtcNow;
|
||||
var next = now.Date.AddHours(5);
|
||||
if (now >= next) next = next.AddDays(1);
|
||||
|
||||
_yearEndTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.UtcNow.Month == 1 && DateTime.UtcNow.Day == 2)
|
||||
RunYearEndAggregation().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Year-end error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
null, next - now, TimeSpan.FromDays(1));
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 05:00 UTC, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm} UTC");
|
||||
}
|
||||
|
||||
// ── Weekly Report Auto-Generation ─────────────────────────────
|
||||
|
||||
private static async Task RunWeeklyReportGeneration()
|
||||
{
|
||||
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
||||
.ToList();
|
||||
|
||||
var generated = 0;
|
||||
foreach (var installation in installations)
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installation.Id, installation.Name, "en");
|
||||
|
||||
SaveWeeklySummary(installation.Id, report, "en");
|
||||
generated++;
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.Name})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Weekly report generation complete. {generated}/{installations.Count} installations processed.");
|
||||
}
|
||||
|
||||
// ── Save Weekly Summary ───────────────────────────────────────────
|
||||
|
||||
public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report, String language = "en")
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove any existing weekly records whose date range overlaps with this report.
|
||||
// Two periods overlap when: existingStart < newEnd AND newStart < existingEnd.
|
||||
// This prevents double-counting when the same days appear in different report windows
|
||||
// (e.g., report for days 1-7, then later 2-8 — the old 1-7 record is removed).
|
||||
var overlapping = Db.WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodStart, report.PeriodEnd, StringComparison.Ordinal) < 0
|
||||
&& String.Compare(report.PeriodStart, r.PeriodEnd, StringComparison.Ordinal) < 0)
|
||||
.ToList();
|
||||
|
||||
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,
|
||||
PeriodStart = report.PeriodStart,
|
||||
PeriodEnd = report.PeriodEnd,
|
||||
TotalPvProduction = report.CurrentWeek.TotalPvProduction,
|
||||
TotalConsumption = report.CurrentWeek.TotalConsumption,
|
||||
TotalGridImport = report.CurrentWeek.TotalGridImport,
|
||||
TotalGridExport = report.CurrentWeek.TotalGridExport,
|
||||
TotalBatteryCharged = report.CurrentWeek.TotalBatteryCharged,
|
||||
TotalBatteryDischarged = report.CurrentWeek.TotalBatteryDischarged,
|
||||
TotalEnergySaved = report.TotalEnergySaved,
|
||||
TotalSavingsCHF = report.TotalSavingsCHF,
|
||||
SelfSufficiencyPercent = report.SelfSufficiencyPercent,
|
||||
SelfConsumptionPercent = report.SelfConsumptionPercent,
|
||||
BatteryEfficiencyPercent = report.BatteryEfficiencyPercent,
|
||||
GridDependencyPercent = report.GridDependencyPercent,
|
||||
PeakLoadHour = report.Behavior?.PeakLoadHour ?? 0,
|
||||
PeakSolarHour = report.Behavior?.PeakSolarHour ?? 0,
|
||||
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
|
||||
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
|
||||
AiInsight = report.AiInsight,
|
||||
ResponseJson = JsonConvert.SerializeObject(reportForCache),
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
Db.Create(summary);
|
||||
|
||||
// Seed AiInsightCache so historical reads for this language are free
|
||||
if (!String.IsNullOrEmpty(summary.AiInsight))
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = "weekly",
|
||||
ReportId = summary.Id,
|
||||
Language = language,
|
||||
InsightText = summary.AiInsight,
|
||||
CreatedAt = summary.CreatedAt,
|
||||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}–{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Failed to save weekly summary for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending Aggregation Queries ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns months that have weekly data but no monthly report yet.
|
||||
/// Each entry: { Year, Month, WeekCount }.
|
||||
/// </summary>
|
||||
public static List<PendingMonth> GetPendingMonthlyAggregations(Int64 installationId)
|
||||
{
|
||||
var weeklies = Db.GetWeeklyReports(installationId);
|
||||
var existingMonths = Db.GetMonthlyReports(installationId)
|
||||
.Select(m => (m.Year, m.Month))
|
||||
.ToHashSet();
|
||||
|
||||
return weeklies
|
||||
.GroupBy(w =>
|
||||
{
|
||||
var date = DateTime.Parse(w.PeriodStart);
|
||||
return (Year: date.Year, Month: date.Month);
|
||||
})
|
||||
.Where(g => !existingMonths.Contains(g.Key))
|
||||
.Select(g => new PendingMonth { Year = g.Key.Year, Month = g.Key.Month, WeekCount = g.Count() })
|
||||
.OrderByDescending(p => p.Year).ThenByDescending(p => p.Month)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns years that have monthly data but no yearly report yet.
|
||||
/// Each entry: { Year, MonthCount }.
|
||||
/// </summary>
|
||||
public static List<PendingYear> GetPendingYearlyAggregations(Int64 installationId)
|
||||
{
|
||||
var monthlies = Db.GetMonthlyReports(installationId);
|
||||
var existingYears = Db.GetYearlyReports(installationId)
|
||||
.Select(y => y.Year)
|
||||
.ToHashSet();
|
||||
|
||||
return monthlies
|
||||
.GroupBy(m => m.Year)
|
||||
.Where(g => !existingYears.Contains(g.Key))
|
||||
.Select(g => new PendingYear { Year = g.Key, MonthCount = g.Count() })
|
||||
.OrderByDescending(p => p.Year)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Month-End Aggregation ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Trigger monthly aggregation for a specific installation and month.
|
||||
/// Re-generates if a monthly report already exists. Weekly records are kept.
|
||||
/// Returns the number of weekly records aggregated (0 = no data).
|
||||
/// </summary>
|
||||
public static async Task<Int32> TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en")
|
||||
{
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var days = Db.GetDailyRecords(installationId, first, last);
|
||||
if (days.Count == 0)
|
||||
return 0;
|
||||
|
||||
await AggregateMonthForInstallation(installationId, year, month, language);
|
||||
return days.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger yearly aggregation for a specific installation and year.
|
||||
/// Re-generates if a yearly report already exists. Monthly records are kept.
|
||||
/// Returns the number of monthly records aggregated (0 = no data).
|
||||
/// </summary>
|
||||
public static async Task<Int32> TriggerYearlyAggregationAsync(Int64 installationId, Int32 year, String language = "en")
|
||||
{
|
||||
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||
if (monthlies.Count == 0)
|
||||
return 0;
|
||||
|
||||
await AggregateYearForInstallation(installationId, year, language);
|
||||
return monthlies.Count;
|
||||
}
|
||||
|
||||
private static async Task RunMonthEndAggregation()
|
||||
{
|
||||
var previousMonth = DateTime.UtcNow.AddMonths(-1);
|
||||
var year = previousMonth.Year;
|
||||
var month = previousMonth.Month;
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}...");
|
||||
|
||||
// Find all installations that have daily records for the previous month
|
||||
var installationIds = Db.DailyRecords
|
||||
.Where(r => String.Compare(r.Date, first.ToString("yyyy-MM-dd"), StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.Date, last.ToString("yyyy-MM-dd"), StringComparison.Ordinal) <= 0)
|
||||
.Select(r => r.InstallationId)
|
||||
.ToList()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var installationId in installationIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AggregateMonthForInstallation(installationId, year, month);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Month aggregation failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en")
|
||||
{
|
||||
// Compute from daily records for the full calendar month
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var days = Db.GetDailyRecords(installationId, first, last);
|
||||
if (days.Count == 0)
|
||||
return;
|
||||
|
||||
// If monthly report already exists, delete it so we can re-generate
|
||||
Db.DeleteMonthlyReport(installationId, year, month);
|
||||
|
||||
// Sum energy totals directly from daily records
|
||||
var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
|
||||
var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
|
||||
var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
|
||||
var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
|
||||
var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
|
||||
|
||||
// Re-derive ratios
|
||||
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||
|
||||
// Get installation name for AI insight
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
|
||||
|
||||
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
|
||||
var aiInsight = await GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, days.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language,
|
||||
weatherCity, installation?.Country, weatherRegion);
|
||||
|
||||
var monthlySummary = new MonthlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
Month = month,
|
||||
PeriodStart = first.ToString("yyyy-MM-dd"),
|
||||
PeriodEnd = last.ToString("yyyy-MM-dd"),
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
AvgPeakLoadHour = 0, // Not available without hourly data; Phase 3 will add
|
||||
AvgPeakSolarHour = 0,
|
||||
AvgWeekdayDailyLoad = 0,
|
||||
AvgWeekendDailyLoad = 0,
|
||||
WeekCount = days.Count, // repurposed as day count
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
Db.Create(monthlySummary);
|
||||
|
||||
// Seed AiInsightCache so the generating language is pre-cached
|
||||
if (!String.IsNullOrEmpty(monthlySummary.AiInsight))
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = "monthly",
|
||||
ReportId = monthlySummary.Id,
|
||||
Language = language,
|
||||
InsightText = monthlySummary.AiInsight,
|
||||
CreatedAt = monthlySummary.CreatedAt,
|
||||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}–{last}).");
|
||||
}
|
||||
|
||||
// ── Year-End Aggregation ──────────────────────────────────────────
|
||||
|
||||
private static async Task RunYearEndAggregation()
|
||||
{
|
||||
var previousYear = DateTime.UtcNow.Year - 1;
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
||||
|
||||
var installationIds = Db.MonthlyReports
|
||||
.Where(r => r.Year == previousYear)
|
||||
.Select(r => r.InstallationId)
|
||||
.ToList()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var installationId in installationIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AggregateYearForInstallation(installationId, previousYear);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Time-based cleanup: delete records older than 1 year, runs after yearly report is created
|
||||
CleanupOldRecords();
|
||||
}
|
||||
|
||||
private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en")
|
||||
{
|
||||
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||
if (monthlies.Count == 0)
|
||||
return;
|
||||
|
||||
// If yearly report already exists, delete it so we can re-generate with latest monthly data.
|
||||
Db.DeleteYearlyReport(installationId, year);
|
||||
|
||||
// Sum energy totals
|
||||
var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1);
|
||||
var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1);
|
||||
var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1);
|
||||
var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1);
|
||||
var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1);
|
||||
|
||||
// Re-derive ratios
|
||||
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||
|
||||
var avgPeakLoad = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakLoadHour));
|
||||
var avgPeakSolar = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakSolarHour));
|
||||
var avgWeekdayLoad = Math.Round(monthlies.Average(m => m.AvgWeekdayDailyLoad), 1);
|
||||
var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1);
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
|
||||
var aiInsight = await GenerateYearlyAiInsightAsync(
|
||||
installationName, year, monthlies.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language);
|
||||
|
||||
var yearlySummary = new YearlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
PeriodStart = monthlies.Min(m => m.PeriodStart),
|
||||
PeriodEnd = monthlies.Max(m => m.PeriodEnd),
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
AvgPeakLoadHour = avgPeakLoad,
|
||||
AvgPeakSolarHour = avgPeakSolar,
|
||||
AvgWeekdayDailyLoad = avgWeekdayLoad,
|
||||
AvgWeekendDailyLoad = avgWeekendLoad,
|
||||
MonthCount = monthlies.Count,
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
Db.Create(yearlySummary);
|
||||
|
||||
// Seed AiInsightCache so the generating language is pre-cached
|
||||
if (!String.IsNullOrEmpty(yearlySummary.AiInsight))
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = "yearly",
|
||||
ReportId = yearlySummary.Id,
|
||||
Language = language,
|
||||
InsightText = yearlySummary.AiInsight,
|
||||
CreatedAt = yearlySummary.CreatedAt,
|
||||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated).");
|
||||
}
|
||||
|
||||
// ── AI Insight Cache ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns cached AI insight for (reportType, reportId, language).
|
||||
/// On cache miss: calls generate(), stores the result, and returns it.
|
||||
/// This is the single entry-point for all per-language insight reads.
|
||||
/// </summary>
|
||||
public static async Task<String> GetOrGenerateInsightAsync(
|
||||
String reportType, Int64 reportId, String language, Func<Task<String>> generate)
|
||||
{
|
||||
var cached = Db.GetCachedInsight(reportType, reportId, language);
|
||||
if (!String.IsNullOrEmpty(cached))
|
||||
return cached;
|
||||
|
||||
var insight = await generate();
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = reportType,
|
||||
ReportId = reportId,
|
||||
Language = language,
|
||||
InsightText = insight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
});
|
||||
return insight;
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored WeeklyReportSummary.</summary>
|
||||
public static Task<String> GetOrGenerateWeeklyInsightAsync(
|
||||
WeeklyReportSummary report, String language)
|
||||
{
|
||||
var installationName = Db.GetInstallationById(report.InstallationId)?.Name
|
||||
?? $"Installation {report.InstallationId}";
|
||||
return GetOrGenerateInsightAsync("weekly", report.Id, language,
|
||||
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs a full WeeklyReportResponse from a cached WeeklyReportSummary.
|
||||
/// Returns null if ResponseJson is empty (old records without cache data).
|
||||
/// AI insight is fetched/generated per-language via AiInsightCache.
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse?> ToWeeklyReportResponseAsync(
|
||||
WeeklyReportSummary summary, String language)
|
||||
{
|
||||
if (String.IsNullOrEmpty(summary.ResponseJson))
|
||||
return null;
|
||||
|
||||
var response = JsonConvert.DeserializeObject<WeeklyReportResponse>(summary.ResponseJson);
|
||||
if (response == null)
|
||||
return null;
|
||||
|
||||
response.AiInsight = await GetOrGenerateWeeklyInsightAsync(summary, language);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
|
||||
public static Task<String> GetOrGenerateMonthlyInsightAsync(
|
||||
MonthlyReportSummary report, String language)
|
||||
{
|
||||
var installation = Db.GetInstallationById(report.InstallationId);
|
||||
var installationName = installation?.Name
|
||||
?? $"Installation {report.InstallationId}";
|
||||
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
|
||||
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
return GetOrGenerateInsightAsync("monthly", report.Id, language,
|
||||
() => GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, report.WeekCount,
|
||||
report.TotalPvProduction, report.TotalConsumption,
|
||||
report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
|
||||
weatherCity, installation?.Country, weatherRegion));
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
|
||||
public static Task<String> GetOrGenerateYearlyInsightAsync(
|
||||
YearlyReportSummary report, String language)
|
||||
{
|
||||
var installationName = Db.GetInstallationById(report.InstallationId)?.Name
|
||||
?? $"Installation {report.InstallationId}";
|
||||
return GetOrGenerateInsightAsync("yearly", report.Id, language,
|
||||
() => GenerateYearlyAiInsightAsync(
|
||||
installationName, report.Year, report.MonthCount,
|
||||
report.TotalPvProduction, report.TotalConsumption,
|
||||
report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
|
||||
}
|
||||
|
||||
// ── Time-Based Cleanup ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Deletes records older than 1 year. Called annually on Jan 2 after
|
||||
/// yearly reports are created, so monthly summaries are still available
|
||||
/// when yearly is computed.
|
||||
/// </summary>
|
||||
private static void CleanupOldRecords()
|
||||
{
|
||||
try
|
||||
{
|
||||
Db.CleanupOldData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Cleanup error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI Insight Generation ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Simplified weekly AI insight generated from the stored WeeklyReportSummary numerical fields.
|
||||
/// Used for historical weeks where the original hourly data is no longer available.
|
||||
/// </summary>
|
||||
private static async Task<String> GenerateWeeklySummaryAiInsightAsync(
|
||||
WeeklyReportSummary summary, String installationName, String language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
return "AI insight unavailable (API key not configured).";
|
||||
|
||||
var langName = GetLanguageName(language);
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write a concise weekly performance summary in {langName} (4 bullet points starting with ""- "").
|
||||
|
||||
WEEKLY FACTS for {summary.PeriodStart} to {summary.PeriodEnd}:
|
||||
- PV production: {summary.TotalPvProduction:F1} kWh | Consumption: {summary.TotalConsumption:F1} kWh
|
||||
- Grid import: {summary.TotalGridImport:F1} kWh | Grid export: {summary.TotalGridExport:F1} kWh
|
||||
- Battery: {summary.TotalBatteryCharged:F1} kWh charged, {summary.TotalBatteryDischarged:F1} kWh discharged
|
||||
- Energy saved: {summary.TotalEnergySaved:F1} kWh = ~{summary.TotalSavingsCHF:F0} CHF
|
||||
- Self-sufficiency: {summary.SelfSufficiencyPercent:F1}% | Grid dependency: {summary.GridDependencyPercent:F1}%
|
||||
|
||||
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks.
|
||||
Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
private static String GetLanguageName(String code) => code switch
|
||||
{
|
||||
"de" => "German",
|
||||
"fr" => "French",
|
||||
"it" => "Italian",
|
||||
_ => "English"
|
||||
};
|
||||
|
||||
private static async Task<String> GenerateMonthlyAiInsightAsync(
|
||||
String installationName, String monthName, Int32 weekCount,
|
||||
Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut,
|
||||
Double totalBattChg, Double totalBattDis,
|
||||
Double energySaved, Double savingsCHF,
|
||||
Double selfSufficiency, Double batteryEff,
|
||||
String language = "en",
|
||||
String? location = null, String? country = null, String? region = null)
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
return "AI insight unavailable (API key not configured).";
|
||||
|
||||
var langName = GetLanguageName(language);
|
||||
|
||||
// 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).
|
||||
|
||||
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)
|
||||
- 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.
|
||||
|
||||
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 5 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
private static async Task<String> GenerateYearlyAiInsightAsync(
|
||||
String installationName, Int32 year, Int32 monthCount,
|
||||
Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut,
|
||||
Double totalBattChg, Double totalBattDis,
|
||||
Double energySaved, Double savingsCHF,
|
||||
Double selfSufficiency, Double batteryEff,
|
||||
String language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
return "AI insight unavailable (API key not configured).";
|
||||
|
||||
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).
|
||||
|
||||
ANNUAL FACTS for {year} ({monthCount} months of data):
|
||||
- Total PV production: {totalPv:F1} kWh
|
||||
- Total consumption: {totalConsump:F1} kWh
|
||||
- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh
|
||||
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged
|
||||
- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh)
|
||||
- Self-sufficiency: {selfSufficiency:F1}%
|
||||
- Battery efficiency: {batteryEff:F1}%
|
||||
|
||||
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.
|
||||
|
||||
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 5 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
private static async Task<String> CallMistralAsync(String apiKey, String prompt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = "mistral-small-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 400,
|
||||
temperature = 0.3
|
||||
};
|
||||
|
||||
var responseText = await MistralUrl
|
||||
.WithHeader("Authorization", $"Bearer {apiKey}")
|
||||
.PostJsonAsync(requestBody)
|
||||
.ReceiveString();
|
||||
|
||||
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
|
||||
var content = (String?)envelope?.choices?[0]?.message?.content;
|
||||
|
||||
if (!String.IsNullOrWhiteSpace(content))
|
||||
return content.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Mistral error: {ex.Message}");
|
||||
}
|
||||
|
||||
return "AI insight could not be generated at this time.";
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,207 @@
|
|||
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, String language = "en")
|
||||
{
|
||||
var ticket = Db.GetTicketById(ticketId);
|
||||
if (ticket is null) return;
|
||||
|
||||
var installation = ticket.InstallationId.HasValue
|
||||
? Db.GetInstallationById(ticket.InstallationId.Value)
|
||||
: null;
|
||||
|
||||
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 = installation != null
|
||||
? ((ProductType)installation.Product).ToString()
|
||||
: "Unknown";
|
||||
|
||||
var recentErrors = ticket.InstallationId.HasValue
|
||||
? Db.Errors
|
||||
.Where(e => e.InstallationId == ticket.InstallationId.Value)
|
||||
.OrderByDescending(e => e.Date)
|
||||
.ToList()
|
||||
.Select(e => e.Description)
|
||||
.Distinct()
|
||||
.Take(5)
|
||||
.ToList()
|
||||
: new List<string>();
|
||||
|
||||
var prompt = BuildPrompt(ticket, productName, recentErrors, language);
|
||||
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, string language = "en")
|
||||
{
|
||||
var recentList = recentErrors.Count > 0
|
||||
? string.Join(", ", recentErrors)
|
||||
: "none";
|
||||
|
||||
var langInstruction = language != "en"
|
||||
? $"\nIMPORTANT: Write all text values (rootCause, recommendedActions) in the language with code \"{language}\". The JSON keys must remain in English."
|
||||
: "";
|
||||
|
||||
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.{langInstruction}";
|
||||
}
|
||||
|
||||
private static async Task<TicketDiagnosisResult?> CallMistralAsync(string prompt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = new
|
||||
{
|
||||
model = "mistral-small-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 300,
|
||||
temperature = 0.2
|
||||
};
|
||||
|
||||
var text = await MistralUrl
|
||||
.WithHeader("Authorization", $"Bearer {_apiKey}")
|
||||
.PostJsonAsync(body)
|
||||
.ReceiveString();
|
||||
|
||||
var envelope = JsonConvert.DeserializeObject<dynamic>(text);
|
||||
var content = (string?)envelope?.choices?[0]?.message?.content;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content)) return null;
|
||||
|
||||
var json = content.Trim();
|
||||
if (json.StartsWith("```"))
|
||||
{
|
||||
var nl = json.IndexOf('\n');
|
||||
if (nl >= 0) json = json[(nl + 1)..];
|
||||
if (json.EndsWith("```")) json = json[..^3];
|
||||
json = json.Trim();
|
||||
}
|
||||
|
||||
var parsed = JsonConvert.DeserializeObject<TicketDiagnosisRaw>(json);
|
||||
if (parsed is null) return null;
|
||||
|
||||
return new TicketDiagnosisResult
|
||||
{
|
||||
RootCause = parsed.RootCause,
|
||||
Confidence = parsed.Confidence,
|
||||
RecommendedActionsJson = JsonConvert.SerializeObject(parsed.RecommendedActions ?? Array.Empty<string>())
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[TicketDiagnosticService] HTTP error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class TicketDiagnosisRaw
|
||||
{
|
||||
[JsonProperty("rootCause")]
|
||||
public String? RootCause { get; set; }
|
||||
|
||||
[JsonProperty("confidence")]
|
||||
public Double? Confidence { get; set; }
|
||||
|
||||
[JsonProperty("recommendedActions")]
|
||||
public String[]? RecommendedActions { get; set; }
|
||||
}
|
||||
|
||||
internal class TicketDiagnosisResult
|
||||
{
|
||||
public String? RootCause { get; set; }
|
||||
public Double? Confidence { get; set; }
|
||||
public String? RecommendedActionsJson { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
using Flurl.Http;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
public static class WeatherService
|
||||
{
|
||||
public record DailyWeather(
|
||||
string Date,
|
||||
double TempMin,
|
||||
double TempMax,
|
||||
double SunshineHours,
|
||||
double PrecipitationMm,
|
||||
string Description
|
||||
);
|
||||
|
||||
private static readonly Dictionary<string, (double Lat, double Lon)> GeoCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a 7-day weather forecast for the given city, or null on any failure.
|
||||
/// </summary>
|
||||
public static async Task<List<DailyWeather>?> GetForecastAsync(string? city, string? country, string? region = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(city))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var coords = await GeocodeAsync(city, region);
|
||||
if (coords == null)
|
||||
return null;
|
||||
|
||||
var (lat, lon) = coords.Value;
|
||||
return await FetchForecastAsync(lat, lon);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WeatherService] Error fetching forecast for '{city}': {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a forecast list into a compact text block for AI prompt injection.
|
||||
/// </summary>
|
||||
public static string FormatForPrompt(List<DailyWeather> forecast)
|
||||
{
|
||||
var lines = forecast.Select(d =>
|
||||
{
|
||||
var date = DateTime.Parse(d.Date);
|
||||
var dayName = date.ToString("ddd dd MMM");
|
||||
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||||
});
|
||||
|
||||
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a geocodable city name from a Location field that may contain a full address.
|
||||
/// Handles Swiss formats like "Street 5, 8604 Volketswil" → "Volketswil"
|
||||
/// Also tries the Region field as fallback.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> ExtractSearchTerms(string city, string? region)
|
||||
{
|
||||
// If it contains a comma, try the part after the last comma (often "PostalCode City")
|
||||
if (city.Contains(','))
|
||||
{
|
||||
var afterComma = city.Split(',').Last().Trim();
|
||||
// Strip leading postal code (digits) → "8604 Volketswil" → "Volketswil"
|
||||
var withoutPostal = System.Text.RegularExpressions.Regex.Replace(afterComma, @"^\d+\s*", "").Trim();
|
||||
if (!string.IsNullOrEmpty(withoutPostal))
|
||||
yield return withoutPostal;
|
||||
if (!string.IsNullOrEmpty(afterComma))
|
||||
yield return afterComma;
|
||||
}
|
||||
|
||||
// Try the raw value as-is
|
||||
yield return city;
|
||||
|
||||
// Fallback to Region
|
||||
if (!string.IsNullOrWhiteSpace(region))
|
||||
yield return region;
|
||||
}
|
||||
|
||||
private static async Task<(double Lat, double Lon)?> GeocodeAsync(string city, string? region = null)
|
||||
{
|
||||
if (GeoCache.TryGetValue(city, out var cached))
|
||||
return cached;
|
||||
|
||||
foreach (var term in ExtractSearchTerms(city, region))
|
||||
{
|
||||
var url = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(term)}&count=1&language=en";
|
||||
var json = await url.GetStringAsync();
|
||||
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||||
|
||||
if (data?.results != null && data.results.Count > 0)
|
||||
{
|
||||
var lat = (double)data.results[0].latitude;
|
||||
var lon = (double)data.results[0].longitude;
|
||||
|
||||
GeoCache[city] = (lat, lon);
|
||||
Console.WriteLine($"[WeatherService] Geocoded '{city}' (matched on '{term}') → ({lat:F4}, {lon:F4})");
|
||||
return (lat, lon);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WeatherService] Could not geocode location: '{city}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<DailyWeather>?> FetchForecastAsync(double lat, double lon)
|
||||
{
|
||||
var url = $"https://api.open-meteo.com/v1/forecast"
|
||||
+ $"?latitude={lat}&longitude={lon}"
|
||||
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
|
||||
+ "&timezone=Europe/Zurich&forecast_days=7";
|
||||
|
||||
var json = await url.GetStringAsync();
|
||||
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||||
|
||||
if (data?.daily == null)
|
||||
return null;
|
||||
|
||||
var dates = data.daily.time;
|
||||
var tempMax = data.daily.temperature_2m_max;
|
||||
var tempMin = data.daily.temperature_2m_min;
|
||||
var sun = data.daily.sunshine_duration;
|
||||
var precip = data.daily.precipitation_sum;
|
||||
var codes = data.daily.weathercode;
|
||||
|
||||
var forecast = new List<DailyWeather>();
|
||||
for (int i = 0; i < dates.Count; i++)
|
||||
{
|
||||
forecast.Add(new DailyWeather(
|
||||
Date: (string)dates[i],
|
||||
TempMin: (double)tempMin[i],
|
||||
TempMax: (double)tempMax[i],
|
||||
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
|
||||
PrecipitationMm: (double)precip[i],
|
||||
Description: WeatherCodeToDescription((int)codes[i])
|
||||
));
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WeatherService] Fetched {forecast.Count}-day forecast.");
|
||||
return forecast;
|
||||
}
|
||||
|
||||
private static string WeatherCodeToDescription(int code) => code switch
|
||||
{
|
||||
0 => "Clear sky",
|
||||
1 => "Mainly clear",
|
||||
2 => "Partly cloudy",
|
||||
3 => "Overcast",
|
||||
45 => "Fog",
|
||||
48 => "Depositing rime fog",
|
||||
51 => "Light drizzle",
|
||||
53 => "Moderate drizzle",
|
||||
55 => "Dense drizzle",
|
||||
61 => "Slight rain",
|
||||
63 => "Moderate rain",
|
||||
65 => "Heavy rain",
|
||||
66 => "Light freezing rain",
|
||||
67 => "Heavy freezing rain",
|
||||
71 => "Slight snow",
|
||||
73 => "Moderate snow",
|
||||
75 => "Heavy snow",
|
||||
77 => "Snow grains",
|
||||
80 => "Slight showers",
|
||||
81 => "Moderate showers",
|
||||
82 => "Violent showers",
|
||||
85 => "Slight snow showers",
|
||||
86 => "Heavy snow showers",
|
||||
95 => "Thunderstorm",
|
||||
96 => "Thunderstorm with slight hail",
|
||||
99 => "Thunderstorm with heavy hail",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using Flurl.Http;
|
||||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
|
@ -9,87 +11,229 @@ public static class WeeklyReportService
|
|||
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
||||
|
||||
/// <summary>
|
||||
/// Generates a full weekly report for the given installation.
|
||||
/// Cache is invalidated automatically when the xlsx file is newer than the cache.
|
||||
/// To force regeneration (e.g. after a prompt change), simply delete the cache files.
|
||||
/// 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 async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
|
||||
public static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
|
||||
{
|
||||
var xlsxPath = TmpReportDir + installationId + ".xlsx";
|
||||
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
|
||||
if (!Directory.Exists(TmpReportDir))
|
||||
return new List<string>();
|
||||
|
||||
// Use cached report if xlsx hasn't changed since cache was written
|
||||
if (File.Exists(cachePath) && File.Exists(xlsxPath))
|
||||
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 xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
|
||||
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
|
||||
if (cacheModified > xlsxModified)
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
// Match pattern: {id}_MMDD_MMDD
|
||||
var match = Regex.Match(name, @"_(\d{4})_(\d{4})$");
|
||||
if (!match.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
|
||||
await File.ReadAllTextAsync(cachePath));
|
||||
if (cached != null)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}).");
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cache corrupt — regenerate
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Parse both daily summaries and hourly intervals from the same xlsx
|
||||
var allDays = ExcelDataParser.Parse(xlsxPath);
|
||||
var allHourly = ExcelDataParser.ParseHourly(xlsxPath);
|
||||
var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Write cache
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
|
||||
}
|
||||
// ── Calendar Week Helpers ──────────────────────────────────────────
|
||||
|
||||
return report;
|
||||
/// <summary>
|
||||
/// Returns the last completed calendar week (Mon–Sun).
|
||||
/// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08).
|
||||
/// </summary>
|
||||
public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
// DayOfWeek: Sun=0, Mon=1, ..., Sat=6
|
||||
// daysSinceMonday: Mon=0, Tue=1, ..., Sun=6
|
||||
var daysSinceMonday = ((Int32)today.DayOfWeek + 6) % 7;
|
||||
var thisMonday = today.AddDays(-daysSinceMonday);
|
||||
var lastMonday = thisMonday.AddDays(-7);
|
||||
var lastSunday = thisMonday.AddDays(-1);
|
||||
return (lastMonday, lastSunday);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core report generation. Accepts both daily summaries and hourly intervals.
|
||||
/// Returns the calendar week before last (for comparison).
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
||||
List<DailyEnergyData> allDays,
|
||||
List<HourlyEnergyData> allHourly,
|
||||
string installationName,
|
||||
string language = "en")
|
||||
private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek()
|
||||
{
|
||||
// Sort by date
|
||||
allDays = allDays.OrderBy(d => d.Date).ToList();
|
||||
var (lastMon, _) = LastCalendarWeek();
|
||||
return (lastMon.AddDays(-7), lastMon.AddDays(-1));
|
||||
}
|
||||
|
||||
// Split into previous week and current week (daily)
|
||||
List<DailyEnergyData> previousWeekDays;
|
||||
List<DailyEnergyData> currentWeekDays;
|
||||
// ── Report Generation ──────────────────────────────────────────────
|
||||
|
||||
if (allDays.Count > 7)
|
||||
/// <summary>
|
||||
/// Generates a full weekly report for the given installation.
|
||||
/// Data source priority:
|
||||
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
|
||||
/// 2. S3 fallback — ingests missing dates into SQLite via IngestDateRangeAsync, then retries
|
||||
/// 3. xlsx file fallback (legacy, if both DB and S3 are empty)
|
||||
/// Cache is keyed to the calendar week — invalidated when the week changes.
|
||||
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportAsync(
|
||||
long installationId, string installationName, string language = "en",
|
||||
DateOnly? weekStartOverride = null)
|
||||
{
|
||||
DateOnly curMon, curSun, prevMon, prevSun;
|
||||
|
||||
if (weekStartOverride.HasValue)
|
||||
{
|
||||
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
|
||||
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
|
||||
// Debug/backfill mode: use the provided Monday as the week start
|
||||
curMon = weekStartOverride.Value;
|
||||
curSun = curMon.AddDays(6);
|
||||
prevMon = curMon.AddDays(-7);
|
||||
prevSun = curMon.AddDays(-1);
|
||||
}
|
||||
else
|
||||
{
|
||||
previousWeekDays = new List<DailyEnergyData>();
|
||||
currentWeekDays = allDays;
|
||||
(curMon, curSun) = LastCalendarWeek();
|
||||
(prevMon, prevSun) = PreviousCalendarWeek();
|
||||
}
|
||||
|
||||
// Restrict hourly data to current week only for behavioral analysis
|
||||
var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date);
|
||||
var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList();
|
||||
// 1. Load daily records from SQLite
|
||||
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
|
||||
.Select(ToDailyEnergyData).ToList();
|
||||
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
||||
.Select(ToDailyEnergyData).ToList();
|
||||
|
||||
// 2. S3 fallback: if DB empty, try ingesting from S3 into SQLite, then retry
|
||||
if (currentWeekDays.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, trying S3 ingestion...");
|
||||
await DailyIngestionService.IngestDateRangeAsync(installationId, prevMon, curSun);
|
||||
currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
|
||||
.Select(ToDailyEnergyData).ToList();
|
||||
previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
||||
.Select(ToDailyEnergyData).ToList();
|
||||
}
|
||||
|
||||
// 3. xlsx fallback: if still empty after S3, parse xlsx on the fly (legacy)
|
||||
if (currentWeekDays.Count == 0)
|
||||
{
|
||||
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
|
||||
|
||||
if (relevantFiles.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReportService] S3 empty, falling back to {relevantFiles.Count} xlsx file(s).");
|
||||
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
||||
currentWeekDays = allDaysParsed
|
||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
||||
.ToList();
|
||||
previousWeekDays = allDaysParsed
|
||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWeekDays.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " +
|
||||
"Upload an xlsx file or wait for daily ingestion.");
|
||||
|
||||
// 4. Load hourly records from SQLite for behavioral analysis
|
||||
// (S3 ingestion above already populated hourly records if available)
|
||||
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
||||
.Select(ToHourlyEnergyData).ToList();
|
||||
|
||||
// 4b. xlsx fallback for hourly data
|
||||
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 = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var country = installation?.Country;
|
||||
var region = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : 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);
|
||||
}
|
||||
|
||||
// ── Conversion helpers ─────────────────────────────────────────────
|
||||
|
||||
private static DailyEnergyData ToDailyEnergyData(DailyEnergyRecord r) => new()
|
||||
{
|
||||
Date = r.Date,
|
||||
PvProduction = r.PvProduction,
|
||||
LoadConsumption = r.LoadConsumption,
|
||||
GridImport = r.GridImport,
|
||||
GridExport = r.GridExport,
|
||||
BatteryCharged = r.BatteryCharged,
|
||||
BatteryDischarged = r.BatteryDischarged,
|
||||
};
|
||||
|
||||
private static HourlyEnergyData ToHourlyEnergyData(HourlyEnergyRecord r) => new()
|
||||
{
|
||||
DateTime = DateTime.ParseExact(r.DateHour, "yyyy-MM-dd HH", null),
|
||||
Hour = r.Hour,
|
||||
DayOfWeek = r.DayOfWeek,
|
||||
IsWeekend = r.IsWeekend,
|
||||
PvKwh = r.PvKwh,
|
||||
LoadKwh = r.LoadKwh,
|
||||
GridImportKwh = r.GridImportKwh,
|
||||
BatteryChargedKwh = r.BatteryChargedKwh,
|
||||
BatteryDischargedKwh = r.BatteryDischargedKwh,
|
||||
BattSoC = r.BattSoC,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Core report generation. Accepts pre-split current/previous week data and hourly intervals.
|
||||
/// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response.
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
||||
List<DailyEnergyData> currentWeekDays,
|
||||
List<DailyEnergyData> previousWeekDays,
|
||||
List<HourlyEnergyData> currentHourlyData,
|
||||
string installationName,
|
||||
string language = "en",
|
||||
DateOnly? weekStart = null,
|
||||
DateOnly? weekEnd = null,
|
||||
string? location = null,
|
||||
string? country = null,
|
||||
string? region = null)
|
||||
{
|
||||
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
|
||||
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
|
||||
|
||||
var currentSummary = Summarize(currentWeekDays);
|
||||
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
||||
|
|
@ -130,13 +274,26 @@ public static class WeeklyReportService
|
|||
var aiInsight = await GetAiInsightAsync(
|
||||
currentWeekDays, currentSummary, previousSummary,
|
||||
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
||||
behavior, installationName, language);
|
||||
behavior, installationName, language, location, country, region);
|
||||
|
||||
// Compute data availability — which days of the week are missing
|
||||
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
|
||||
var missingDates = new List<String>();
|
||||
if (weekStart.HasValue && weekEnd.HasValue)
|
||||
{
|
||||
for (var d = weekStart.Value; d <= weekEnd.Value; d = d.AddDays(1))
|
||||
{
|
||||
var iso = d.ToString("yyyy-MM-dd");
|
||||
if (!availableDates.Contains(iso))
|
||||
missingDates.Add(iso);
|
||||
}
|
||||
}
|
||||
|
||||
return new WeeklyReportResponse
|
||||
{
|
||||
InstallationName = installationName,
|
||||
PeriodStart = currentWeekDays.First().Date,
|
||||
PeriodEnd = currentWeekDays.Last().Date,
|
||||
PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date,
|
||||
PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date,
|
||||
CurrentWeek = currentSummary,
|
||||
PreviousWeek = previousSummary,
|
||||
TotalEnergySaved = totalEnergySaved,
|
||||
|
|
@ -149,9 +306,12 @@ public static class WeeklyReportService
|
|||
PvChangePercent = pvChange,
|
||||
ConsumptionChangePercent = consumptionChange,
|
||||
GridImportChangePercent = gridImportChange,
|
||||
DailyData = allDays,
|
||||
DailyData = currentWeekDays,
|
||||
Behavior = behavior,
|
||||
AiInsight = aiInsight,
|
||||
DaysAvailable = currentWeekDays.Count,
|
||||
DaysExpected = 7,
|
||||
MissingDates = missingDates,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +355,10 @@ public static class WeeklyReportService
|
|||
double totalSavingsCHF,
|
||||
BehavioralPattern behavior,
|
||||
string installationName,
|
||||
string language = "en")
|
||||
string language = "en",
|
||||
string? location = null,
|
||||
string? country = null,
|
||||
string? region = null)
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
|
|
@ -204,6 +367,12 @@ 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
|
||||
|
|
@ -220,7 +389,10 @@ public static class WeeklyReportService
|
|||
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
|
||||
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
|
||||
|
||||
// Behavioral facts as compact lines
|
||||
// Check if we have meaningful hourly/behavioral data
|
||||
var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0;
|
||||
|
||||
// Behavioral facts as compact lines (only when hourly data exists)
|
||||
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "–" + FormatHour(behavior.PeakSolarEndHour);
|
||||
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
|
||||
|
||||
|
|
@ -245,15 +417,27 @@ public static class WeeklyReportService
|
|||
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
|
||||
: "";
|
||||
|
||||
var pvBehaviorLines = hasPv ? $@"
|
||||
// Behavioral section — only include when hourly data exists
|
||||
var behavioralSection = "";
|
||||
if (hasBehavior)
|
||||
{
|
||||
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}" : "";
|
||||
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
|
||||
|
||||
behavioralSection = $@"
|
||||
BEHAVIORAL PATTERN (from hourly data this week):
|
||||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||||
- {weekdayWeekendLine}{pvBehaviorLines}
|
||||
{gridBehaviorLine}
|
||||
{battBehaviorLine}";
|
||||
}
|
||||
|
||||
// Build conditional instructions
|
||||
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.";
|
||||
|
|
@ -261,22 +445,55 @@ public static class WeeklyReportService
|
|||
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 and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})."
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
|
||||
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
|
||||
|
||||
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.";
|
||||
|
||||
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.";
|
||||
// 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 prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||
Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||
|
||||
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
|
||||
|
||||
|
|
@ -289,20 +506,16 @@ DAILY FACTS:
|
|||
{pvDailyFact}
|
||||
{battDailyFact}
|
||||
{gridDailyFact}
|
||||
|
||||
BEHAVIORAL PATTERN (from hourly data this week):
|
||||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||||
- {weekdayWeekendLine}{pvBehaviorLines}
|
||||
{gridBehaviorLine}
|
||||
{battBehaviorLine}
|
||||
|
||||
{behavioralSection}
|
||||
{weatherBlock}
|
||||
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 4 bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly 4 bullet points — nothing before, nothing after.
|
||||
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} bullet points — nothing before, nothing after.
|
||||
IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
||||
|
||||
try
|
||||
|
|
@ -311,7 +524,7 @@ IMPORTANT: Write your entire response in {LanguageName(language)}.";
|
|||
{
|
||||
model = "mistral-small-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 400,
|
||||
max_tokens = 600,
|
||||
temperature = 0.3
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ public static class RabbitMqManager
|
|||
monitorLink =
|
||||
$"https://monitor.inesco.energy/sodistore_installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
else if (installation.Product == (int)ProductType.SodistoreGrid)
|
||||
{
|
||||
monitorLink =
|
||||
$"https://monitor.inesco.energy/sodistoregrid_installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
else if (installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
monitorLink =
|
||||
$"https://monitor.inesco.energy/sodistorepro_installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
else
|
||||
{
|
||||
monitorLink =
|
||||
|
|
@ -125,10 +135,10 @@ public static class RabbitMqManager
|
|||
{
|
||||
Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
|
||||
string recipient = "support@innov.energy";
|
||||
string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken";
|
||||
string text = $"Dear InnovEnergy Support Team,\n" +
|
||||
string subject = $"Battery Alarm from {installation.Name}: 2 or more strings broken";
|
||||
string text = $"Dear inesco energy Support Team,\n" +
|
||||
$"\n"+
|
||||
$"Installation Name: {installation.InstallationName}\n"+
|
||||
$"Installation Name: {installation.Name}\n"+
|
||||
$"\n"+
|
||||
$"Installation Monitor Link: {monitorLink}\n"+
|
||||
$"\n"+
|
||||
|
|
@ -138,7 +148,7 @@ public static class RabbitMqManager
|
|||
$"\n"+
|
||||
$"Thank you for your great support:)";
|
||||
//Disable this function now
|
||||
//Mailer.Send("InnovEnergy Support Team", recipient, subject, text);
|
||||
//Mailer.Send("inesco energy Support Team", recipient, subject, text);
|
||||
}
|
||||
//Create a new error and add it to the database
|
||||
Db.HandleError(newError, installationId);
|
||||
|
|
@ -174,8 +184,7 @@ public static class RabbitMqManager
|
|||
Console.WriteLine("RECEIVED A HEARTBIT FROM prototype, time is "+ WebsocketManager.InstallationConnections[installationId].Timestamp);
|
||||
}
|
||||
|
||||
installation.Status = receivedStatusMessage.Status;
|
||||
installation.Apply(Db.Update);
|
||||
Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status);
|
||||
|
||||
//Console.WriteLine("----------------------------------------------");
|
||||
//If the status has changed, update all the connected front-ends regarding this installation
|
||||
|
|
|
|||
|
|
@ -30,16 +30,16 @@ public static class WebsocketManager
|
|||
if ((installationConnection.Value.Product == (int)ProductType.Salimax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2))
|
||||
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodistorePro && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4))
|
||||
)
|
||||
{
|
||||
Console.WriteLine("Installation ID is " + installationConnection.Key);
|
||||
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
||||
|
||||
installationConnection.Value.Status = (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);
|
||||
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
||||
if (installationConnection.Value.Connections.Count > 0)
|
||||
{
|
||||
idsToInform.Add(installationConnection.Key);
|
||||
|
|
@ -60,12 +60,29 @@ 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);
|
||||
|
||||
var jsonObject = new
|
||||
|
|
@ -81,20 +98,18 @@ public static class WebsocketManager
|
|||
|
||||
// Send to all connections concurrently (preserves original fire-and-forget intent),
|
||||
// but isolate failures so one closed socket doesn't affect others or crash the caller.
|
||||
await Task.WhenAll(connections
|
||||
.Where(c => c.State == WebSocketState.Open)
|
||||
.Select(async c =>
|
||||
await Task.WhenAll(connections.Select(async c =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await c.SendAsync(new ArraySegment<byte>(dataToSend, 0, dataToSend.Length),
|
||||
WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"WebSocket send failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}));
|
||||
await c.SendAsync(new ArraySegment<byte>(dataToSend, 0, dataToSend.Length),
|
||||
WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"WebSocket send failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -162,7 +177,10 @@ public static class WebsocketManager
|
|||
};
|
||||
}
|
||||
|
||||
InstallationConnections[installationId].Connections.Add(currentWebSocket);
|
||||
if (!InstallationConnections[installationId].Connections.Contains(currentWebSocket))
|
||||
{
|
||||
InstallationConnections[installationId].Connections.Add(currentWebSocket);
|
||||
}
|
||||
|
||||
var jsonObject = new WebsocketMessage
|
||||
{
|
||||
|
|
@ -196,13 +214,9 @@ public static class WebsocketManager
|
|||
lock (InstallationConnections)
|
||||
{
|
||||
//When the front-end terminates the connection, the following code will be executed
|
||||
//Console.WriteLine("The connection has been terminated");
|
||||
foreach (var installationConnection in InstallationConnections)
|
||||
{
|
||||
if (installationConnection.Value.Connections.Contains(currentWebSocket))
|
||||
{
|
||||
installationConnection.Value.Connections.Remove(currentWebSocket);
|
||||
}
|
||||
installationConnection.Value.Connections.RemoveAll(ws => ws == currentWebSocket);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +237,15 @@ public static class WebsocketManager
|
|||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("WebSocket error: " + ex.Message);
|
||||
|
||||
// Ensure stale socket is removed even on unexpected errors
|
||||
lock (InstallationConnections)
|
||||
{
|
||||
foreach (var installationConnection in InstallationConnections)
|
||||
{
|
||||
installationConnection.Value.Connections.RemoveAll(ws => ws == currentWebSocket);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -201,10 +201,65 @@ public static class S3
|
|||
return response.HttpStatusCode == HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
public static async Task<Boolean> PurgeBucket(this S3Bucket bucket)
|
||||
{
|
||||
var client = bucket.Region.GetS3Client();
|
||||
var totalDeleted = 0;
|
||||
|
||||
Console.WriteLine($"[PurgeBucket] Starting purge of bucket {bucket.Name}");
|
||||
|
||||
while (true)
|
||||
{
|
||||
var listResponse = await client.ListObjectsV2Async(new ListObjectsV2Request
|
||||
{
|
||||
BucketName = bucket.Name,
|
||||
MaxKeys = 1000
|
||||
});
|
||||
|
||||
if (listResponse.S3Objects.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"[PurgeBucket] Completed purge of bucket {bucket.Name}: {totalDeleted} objects deleted");
|
||||
return true;
|
||||
}
|
||||
|
||||
var deleteResponse = await client.DeleteObjectsAsync(new DeleteObjectsRequest
|
||||
{
|
||||
BucketName = bucket.Name,
|
||||
Objects = listResponse.S3Objects
|
||||
.Select(o => new KeyVersion { Key = o.Key })
|
||||
.ToList()
|
||||
});
|
||||
|
||||
if (deleteResponse.HttpStatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
Console.WriteLine($"[PurgeBucket] Failed at batch after {totalDeleted} objects deleted from bucket {bucket.Name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
totalDeleted += listResponse.S3Objects.Count;
|
||||
|
||||
if (totalDeleted % 10000 == 0)
|
||||
Console.WriteLine($"[PurgeBucket] Progress: {totalDeleted} objects deleted from bucket {bucket.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<Boolean> DeleteObjects(this S3Bucket bucket, IReadOnlyList<String> keys)
|
||||
{
|
||||
if (keys.Count == 0) return true;
|
||||
|
||||
var response = await bucket.Region.GetS3Client().DeleteObjectsAsync(new DeleteObjectsRequest
|
||||
{
|
||||
BucketName = bucket.Name,
|
||||
Objects = keys.Select(k => new KeyVersion { Key = k }).ToList()
|
||||
});
|
||||
|
||||
return response.HttpStatusCode == HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
public static async Task<Boolean> DeleteBucket(this S3Region region, String bucketName)
|
||||
{
|
||||
var request = new DeleteBucketRequest { BucketName = bucketName };
|
||||
|
||||
|
||||
var response = await region
|
||||
.GetS3Client()
|
||||
.DeleteBucketAsync(request);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"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",
|
||||
|
|
@ -2876,6 +2877,11 @@
|
|||
"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",
|
||||
|
|
@ -8195,6 +8201,12 @@
|
|||
"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",
|
||||
|
|
@ -8205,7 +8217,6 @@
|
|||
"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"
|
||||
}
|
||||
|
|
@ -11465,6 +11476,11 @@
|
|||
"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",
|
||||
|
|
@ -15414,6 +15430,16 @@
|
|||
"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",
|
||||
|
|
@ -17273,6 +17299,41 @@
|
|||
"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",
|
||||
|
|
@ -17392,6 +17453,15 @@
|
|||
"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",
|
||||
|
|
@ -17423,6 +17493,44 @@
|
|||
"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",
|
||||
|
|
@ -18263,6 +18371,16 @@
|
|||
"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",
|
||||
|
|
@ -19771,6 +19889,15 @@
|
|||
"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",
|
||||
|
|
@ -23174,6 +23301,11 @@
|
|||
"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",
|
||||
|
|
@ -27055,6 +27187,11 @@
|
|||
"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",
|
||||
|
|
@ -27064,8 +27201,7 @@
|
|||
"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==",
|
||||
"dev": true
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
||||
},
|
||||
"default-gateway": {
|
||||
"version": "6.0.3",
|
||||
|
|
@ -29458,6 +29594,11 @@
|
|||
"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",
|
||||
|
|
@ -32446,6 +32587,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"popper.js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
|
||||
},
|
||||
"possible-typed-array-names": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
|
|
@ -33627,6 +33773,39 @@
|
|||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"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",
|
||||
|
|
@ -33725,6 +33904,12 @@
|
|||
"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",
|
||||
|
|
@ -33747,6 +33932,36 @@
|
|||
"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",
|
||||
|
|
@ -34334,6 +34549,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"scroll": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
|
||||
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="
|
||||
},
|
||||
"scrollparent": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
|
|
@ -35520,6 +35745,15 @@
|
|||
"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,6 +38,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Inesco Energy</title>
|
||||
<title>inesco energy</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ 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';
|
||||
import Tickets from './content/dashboards/Tickets';
|
||||
|
||||
function App() {
|
||||
const context = useContext(UserContext);
|
||||
|
|
@ -32,14 +34,41 @@ function App() {
|
|||
const searchParams = new URLSearchParams(location.search);
|
||||
const username = searchParams.get('username');
|
||||
const {
|
||||
accessToSalimax,
|
||||
accessToSodiohome,
|
||||
accessToSodistore,
|
||||
accessToSodistoreGrid,
|
||||
accessToSodistorePro,
|
||||
setAccessToSalimax,
|
||||
setAccessToSalidomo,
|
||||
setAccessToSodiohome,
|
||||
setAccessToSodistore
|
||||
setAccessToSodistore,
|
||||
setAccessToSodistoreGrid,
|
||||
setAccessToSodistorePro
|
||||
} = useContext(ProductIdContext);
|
||||
|
||||
const defaultRoute = accessToSodiohome
|
||||
? routes.sodiohome_installations
|
||||
: accessToSodistorePro
|
||||
? routes.sodistorepro_installations
|
||||
: accessToSodistoreGrid
|
||||
? routes.sodistoregrid_installations
|
||||
: accessToSodistore
|
||||
? routes.sodistore_installations
|
||||
: accessToSalimax
|
||||
? routes.installations
|
||||
: routes.salidomo_installations;
|
||||
|
||||
const detectBrowserLanguage = (): string => {
|
||||
const browserLang = navigator.language?.toLowerCase() || '';
|
||||
if (browserLang.startsWith('de')) return 'de';
|
||||
if (browserLang.startsWith('fr')) return 'fr';
|
||||
if (browserLang.startsWith('it')) return 'it';
|
||||
return 'en';
|
||||
};
|
||||
|
||||
const [language, setLanguage] = useState<string>(
|
||||
() => localStorage.getItem('language') || currentUser?.language || 'en'
|
||||
() => localStorage.getItem('language') || currentUser?.language || detectBrowserLanguage()
|
||||
);
|
||||
|
||||
const onSelectLanguage = (lang: string) => {
|
||||
|
|
@ -91,54 +120,57 @@ function App() {
|
|||
const Login = Loader(lazy(() => import('src/components/login')));
|
||||
const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
|
||||
|
||||
const loginToResetPassword = () => {
|
||||
useEffect(() => {
|
||||
if (!username || token) return;
|
||||
|
||||
axiosConfigWithoutToken
|
||||
.post('/Login', null, { params: { username, password: '' } })
|
||||
.then((response) => {
|
||||
if (response.data && response.data.token) {
|
||||
// Clear the username param from URL to prevent re-login loops
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('username');
|
||||
url.searchParams.delete('reset');
|
||||
window.history.replaceState({}, '', url.pathname);
|
||||
|
||||
setNewToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
setAccessToSalimax(response.data.accessToSalimax);
|
||||
setAccessToSalidomo(response.data.accessToSalidomo);
|
||||
setAccessToSodiohome(response.data.accessToSodioHome);
|
||||
setAccessToSodistore(response.data.accessToSodistoreMax);
|
||||
if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else if (response.data.accessToSalidomo) {
|
||||
navigate(routes.salidomo_installations);
|
||||
} else if (response.data.accessToSodistoreMax) {
|
||||
navigate(routes.sodistore_installations);
|
||||
} else {
|
||||
navigate(routes.sodiohome_installations);
|
||||
}
|
||||
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
||||
setAccessToSodistorePro(response.data.accessToSodistorePro);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
if (username) {
|
||||
loginToResetPassword();
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<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
|
||||
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>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -146,8 +178,14 @@ function App() {
|
|||
if (token && currentUser?.mustResetPassword) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CssBaseline />
|
||||
<SetNewPassword></SetNewPassword>
|
||||
<IntlProvider
|
||||
messages={getTranslations()}
|
||||
locale={language}
|
||||
defaultLocale="en"
|
||||
>
|
||||
<CssBaseline />
|
||||
<SetNewPassword></SetNewPassword>
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -159,16 +197,17 @@ function App() {
|
|||
locale={language}
|
||||
defaultLocale="en"
|
||||
>
|
||||
<TourProvider>
|
||||
<InstallationsContextProvider>
|
||||
<CssBaseline />
|
||||
<Routes>
|
||||
<Route
|
||||
path={''}
|
||||
element={<Navigate to={routes.installations}></Navigate>}
|
||||
element={<Navigate to={defaultRoute}></Navigate>}
|
||||
></Route>
|
||||
<Route
|
||||
path={'login'}
|
||||
element={<Navigate to={routes.installations}></Navigate>}
|
||||
element={<Navigate to={defaultRoute}></Navigate>}
|
||||
></Route>
|
||||
<Route
|
||||
path="/"
|
||||
|
|
@ -215,15 +254,35 @@ function App() {
|
|||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={routes.sodistorepro_installations + '*'}
|
||||
element={
|
||||
<AccessContextProvider>
|
||||
<SodioHomeInstallationTabs product={5} />
|
||||
</AccessContextProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={routes.sodistoregrid_installations + '*'}
|
||||
element={
|
||||
<AccessContextProvider>
|
||||
<InstallationTabs product={4} />
|
||||
</AccessContextProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path={routes.tickets + '*'} element={<Tickets />} />
|
||||
<Route path={routes.users + '*'} element={<Users />} />
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.installations}></Navigate>}
|
||||
element={<Navigate to={defaultRoute}></Navigate>}
|
||||
></Route>
|
||||
<Route path="ResetPassword" element={<ResetPassword />}></Route>
|
||||
</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');
|
||||
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||
const token = tokenString !== null ? tokenString : '';
|
||||
if (token) {
|
||||
config.params['authToken'] = token;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
|
|
@ -4,6 +4,8 @@
|
|||
"salidomo_installations": "/salidomo_installations/",
|
||||
"sodistore_installations": "/sodistore_installations/",
|
||||
"sodiohome_installations": "/sodiohome_installations/",
|
||||
"sodistoregrid_installations": "/sodistoregrid_installations/",
|
||||
"sodistorepro_installations": "/sodistorepro_installations/",
|
||||
"installation": "installation/",
|
||||
"login": "/login/",
|
||||
"forgotPassword": "/forgotPassword/",
|
||||
|
|
@ -21,5 +23,7 @@
|
|||
"history": "history",
|
||||
"mainstats": "mainstats",
|
||||
"detailed_view": "detailed_view/",
|
||||
"report": "report"
|
||||
"report": "report",
|
||||
"installationTickets": "installationTickets",
|
||||
"tickets": "/tickets/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const CURRENT_TERMS_VERSION = 2;
|
||||
|
||||
interface AcknowledgementDialogProps {
|
||||
open: boolean;
|
||||
onAcknowledge: () => void;
|
||||
}
|
||||
|
||||
const AcknowledgementDialog: React.FC<AcknowledgementDialogProps> = ({
|
||||
open,
|
||||
onAcknowledge
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="terms_dialog_title"
|
||||
defaultMessage="Welcome to inesco energy"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box mb={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="terms_data_heading"
|
||||
defaultMessage="Your Data"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="terms_data_body"
|
||||
defaultMessage="Your installation data is securely stored in Switzerland. We do not share your data with third parties."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="terms_ai_heading"
|
||||
defaultMessage="AI-Powered Insights"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="terms_ai_body"
|
||||
defaultMessage="We use an AI service hosted in the EU to provide diagnostics and insights for your installations. AI-generated results are recommendations and should be verified by qualified personnel."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="terms_cookies_heading"
|
||||
defaultMessage="Browser Storage"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="terms_cookies_body"
|
||||
defaultMessage="This platform stores login and preference settings in your browser to keep you signed in and remember your language choice."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={onAcknowledge}>
|
||||
<FormattedMessage
|
||||
id="terms_acknowledge_button"
|
||||
defaultMessage="I understand"
|
||||
/>
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcknowledgementDialog;
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface DataPrivacyDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DataPrivacyDialog: React.FC<DataPrivacyDialogProps> = ({
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="privacy_dialog_title"
|
||||
defaultMessage="Data & Privacy"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box mb={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_data_heading"
|
||||
defaultMessage="Where is my data stored?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_data_body"
|
||||
defaultMessage="Your installation data is stored on servers in Switzerland. Only authorized inesco energy personnel can access your data for support purposes."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_ai_heading"
|
||||
defaultMessage="How is AI used?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_ai_body"
|
||||
defaultMessage="We use an AI service hosted in the European Union to analyze your installation data and provide diagnostic insights. The AI processes technical data such as battery readings and error codes. AI recommendations should always be verified by qualified personnel."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_browser_heading"
|
||||
defaultMessage="What does my browser store?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_browser_body"
|
||||
defaultMessage="Your browser stores your login session to keep you signed in, and your language and theme preferences. No tracking or advertising cookies are used."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_access_heading"
|
||||
defaultMessage="Who has access to my data?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_access_body"
|
||||
defaultMessage="Your data is not shared with third parties. It is used solely to operate the platform and provide you with insights about your installations."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id="privacy_close_button"
|
||||
defaultMessage="Close"
|
||||
/>
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataPrivacyDialog;
|
||||
|
|
@ -18,7 +18,7 @@ function Footer() {
|
|||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1">
|
||||
© 2025 - Inesco Energy AG
|
||||
© 2025 - inesco energy AG
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
|
|
@ -33,7 +33,7 @@ function Footer() {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Inesco Energy AG
|
||||
inesco energy AG
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ 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;
|
||||
|
|
@ -29,6 +30,7 @@ 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);
|
||||
|
||||
|
|
@ -105,7 +107,7 @@ function ForgotPassword() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Provide your username
|
||||
<FormattedMessage id="provideYourUsername" defaultMessage="Provide your username" />
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -118,7 +120,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label="User Name"
|
||||
label={intl.formatMessage({ id: 'userName', defaultMessage: 'User Name' })}
|
||||
variant="outlined"
|
||||
type="username"
|
||||
value={username}
|
||||
|
|
@ -150,7 +152,7 @@ function ForgotPassword() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
|
|
@ -176,7 +178,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Username is wrong. Please try again.
|
||||
<FormattedMessage id="usernameWrong" defaultMessage="Username is wrong. Please try again." />
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -188,7 +190,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
onClick={() => setErrorModalOpen(false)}
|
||||
>
|
||||
Close
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
@ -216,7 +218,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Mail sent successfully.
|
||||
<FormattedMessage id="mailSentSuccessfully" defaultMessage="Mail sent successfully." />
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -228,7 +230,7 @@ function ForgotPassword() {
|
|||
}}
|
||||
onClick={handleReturn}
|
||||
>
|
||||
Close
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ function Logo() {
|
|||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TooltipWrapper title="InnovEnergy" arrow>
|
||||
<TooltipWrapper title="inesco energy" arrow>
|
||||
<LogoWrapper to="/overview">
|
||||
<Badge
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ 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();
|
||||
|
|
@ -102,7 +103,7 @@ function ResetPassword() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Reset Password
|
||||
<FormattedMessage id="resetPassword" defaultMessage="Reset Password" />
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -115,7 +116,7 @@ function ResetPassword() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Password"
|
||||
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password}
|
||||
|
|
@ -126,7 +127,7 @@ function ResetPassword() {
|
|||
sx={{ width: 350 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Verify Password"
|
||||
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
|
||||
type="password"
|
||||
variant="outlined"
|
||||
value={verifypassword}
|
||||
|
|
@ -147,7 +148,7 @@ function ResetPassword() {
|
|||
variant="h5"
|
||||
sx={{ color: '#FF0000', marginTop: 1 }}
|
||||
>
|
||||
Passwords do not match
|
||||
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ function ResetPassword() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
|
|
@ -190,7 +191,7 @@ function ResetPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Reset Password failed. Please try again.
|
||||
<FormattedMessage id="resetPasswordFailed" defaultMessage="Reset Password failed. Please try again." />
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -202,7 +203,7 @@ function ResetPassword() {
|
|||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Close
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ 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();
|
||||
|
|
@ -103,7 +104,7 @@ function SetNewPassword() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Set New Password
|
||||
<FormattedMessage id="setNewPassword" defaultMessage="Set New Password" />
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -116,7 +117,7 @@ function SetNewPassword() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Password"
|
||||
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password}
|
||||
|
|
@ -127,7 +128,7 @@ function SetNewPassword() {
|
|||
sx={{ width: 350 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Verify Password"
|
||||
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
|
||||
type="password"
|
||||
variant="outlined"
|
||||
value={verifypassword}
|
||||
|
|
@ -148,7 +149,7 @@ function SetNewPassword() {
|
|||
variant="h5"
|
||||
sx={{ color: '#FF0000', marginTop: 1 }}
|
||||
>
|
||||
Passwords do not match
|
||||
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
|
@ -165,7 +166,7 @@ function SetNewPassword() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
|
|
@ -191,7 +192,7 @@ function SetNewPassword() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Setting new password failed. Please try again.
|
||||
<FormattedMessage id="setNewPasswordFailed" defaultMessage="Setting new password failed. Please try again." />
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -203,7 +204,7 @@ function SetNewPassword() {
|
|||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Close
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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';
|
||||
|
|
@ -25,6 +24,7 @@ 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,13 +34,16 @@ function Login() {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const context = useContext(UserContext);
|
||||
const {
|
||||
setAccessToSalimax,
|
||||
setAccessToSalidomo,
|
||||
setAccessToSodiohome,
|
||||
setAccessToSodistore
|
||||
setAccessToSodistore,
|
||||
setAccessToSodistoreGrid,
|
||||
setAccessToSodistorePro
|
||||
} = useContext(ProductIdContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -51,7 +54,6 @@ 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);
|
||||
|
|
@ -77,26 +79,27 @@ function Login() {
|
|||
if (response.data && response.data.token) {
|
||||
setLoading(false);
|
||||
|
||||
setNewToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
setNewToken(response.data.token, rememberMe);
|
||||
setUser(response.data.user, rememberMe);
|
||||
|
||||
setAccessToSalimax(response.data.accessToSalimax);
|
||||
setAccessToSalidomo(response.data.accessToSalidomo);
|
||||
setAccessToSodiohome(response.data.accessToSodioHome);
|
||||
setAccessToSodistore(response.data.accessToSodistoreMax);
|
||||
|
||||
if (rememberMe) {
|
||||
cookies.set('rememberedUsername', username, { path: '/' });
|
||||
cookies.set('rememberedPassword', password, { path: '/' });
|
||||
}
|
||||
if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else if (response.data.accessToSalidomo) {
|
||||
navigate(routes.salidomo_installations);
|
||||
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
||||
setAccessToSodistorePro(response.data.accessToSodistorePro);
|
||||
if (response.data.accessToSodioHome) {
|
||||
navigate(routes.sodiohome_installations);
|
||||
} else if (response.data.accessToSodistorePro) {
|
||||
navigate(routes.sodistorepro_installations);
|
||||
} else if (response.data.accessToSodistoreGrid) {
|
||||
navigate(routes.sodistoregrid_installations);
|
||||
} else if (response.data.accessToSodistoreMax) {
|
||||
navigate(routes.sodistore_installations);
|
||||
} else if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else {
|
||||
navigate(routes.sodiohome_installations);
|
||||
navigate(routes.salidomo_installations);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -143,7 +146,7 @@ function Login() {
|
|||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign in
|
||||
<FormattedMessage id="signIn" defaultMessage="Sign in" />
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
|
|
@ -156,7 +159,7 @@ function Login() {
|
|||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Username"
|
||||
label={intl.formatMessage({ id: 'username', defaultMessage: 'Username' })}
|
||||
value={username}
|
||||
onChange={handleUsernameChange}
|
||||
fullWidth
|
||||
|
|
@ -172,7 +175,7 @@ function Login() {
|
|||
/>
|
||||
|
||||
<TextField
|
||||
label="Password"
|
||||
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password}
|
||||
|
|
@ -199,7 +202,7 @@ function Login() {
|
|||
style={{ marginLeft: -175 }}
|
||||
/>
|
||||
}
|
||||
label="Remember me"
|
||||
label={<FormattedMessage id="rememberMe" defaultMessage="Remember me" />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
@ -214,7 +217,7 @@ function Login() {
|
|||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Login
|
||||
<FormattedMessage id="login" defaultMessage="Login" />
|
||||
</Button>
|
||||
|
||||
{loading && (
|
||||
|
|
@ -249,7 +252,7 @@ function Login() {
|
|||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Login failed. Please try again.
|
||||
<FormattedMessage id="loginFailed" defaultMessage="Login failed. Please try again." />
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
|
|
@ -261,7 +264,7 @@ function Login() {
|
|||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Close
|
||||
<FormattedMessage id="close" defaultMessage="Close" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
@ -277,7 +280,7 @@ function Login() {
|
|||
onForgotPassword();
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
<FormattedMessage id="forgotPasswordLink" defaultMessage="Forgot password?" />
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { Step } from 'react-joyride';
|
||||
import { IntlShape } from 'react-intl';
|
||||
|
||||
// --- Build a single step with i18n ---
|
||||
|
||||
function makeStep(
|
||||
intl: IntlShape,
|
||||
target: string,
|
||||
titleId: string,
|
||||
contentId: string,
|
||||
placement: Step['placement'] = 'bottom',
|
||||
disableBeacon = false
|
||||
): Step {
|
||||
return {
|
||||
target,
|
||||
title: intl.formatMessage({ id: titleId }),
|
||||
content: intl.formatMessage({ id: contentId }),
|
||||
placement,
|
||||
...(disableBeacon ? { disableBeacon: true } : {})
|
||||
};
|
||||
}
|
||||
|
||||
// --- Tab key → i18n key mapping ---
|
||||
|
||||
const tabConfig: Record<string, { titleId: string; contentId: string }> = {
|
||||
list: { titleId: 'tourListTitle', contentId: 'tourListContent' },
|
||||
tree: { titleId: 'tourTreeTitle', contentId: 'tourTreeContent' },
|
||||
live: { titleId: 'tourLiveTitle', contentId: 'tourLiveContent' },
|
||||
overview: { titleId: 'tourOverviewTitle', contentId: 'tourOverviewContent' },
|
||||
batteryview: { titleId: 'tourBatteryviewTitle', contentId: 'tourBatteryviewContent' },
|
||||
pvview: { titleId: 'tourPvviewTitle', contentId: 'tourPvviewContent' },
|
||||
log: { titleId: 'tourLogTitle', contentId: 'tourLogContent' },
|
||||
information: { titleId: 'tourInformationTitle', contentId: 'tourInformationContent' },
|
||||
report: { titleId: 'tourReportTitle', contentId: 'tourReportContent' },
|
||||
manage: { titleId: 'tourManageTitle', contentId: 'tourManageContent' },
|
||||
configuration: { titleId: 'tourConfigurationTitle', contentId: 'tourConfigurationContent' },
|
||||
history: { titleId: 'tourHistoryTitle', contentId: 'tourHistoryContent' }
|
||||
};
|
||||
|
||||
// Steps to skip inside a specific installation (already covered in the list-page tour)
|
||||
const listPageOnlyTabs = new Set(['list', 'tree']);
|
||||
|
||||
// --- Build tour steps from tab value list ---
|
||||
|
||||
function buildTourSteps(intl: IntlShape, tabValues: string[], includeInstallationHint = false, isInsideInstallation = false): Step[] {
|
||||
const steps: Step[] = [];
|
||||
if (!isInsideInstallation) {
|
||||
steps.push(makeStep(intl, '[data-tour="language-selector"]', 'tourLanguageTitle', 'tourLanguageContent', 'bottom', true));
|
||||
}
|
||||
for (const value of tabValues) {
|
||||
if (isInsideInstallation && listPageOnlyTabs.has(value)) continue;
|
||||
const cfg = tabConfig[value];
|
||||
if (cfg) {
|
||||
steps.push(makeStep(intl, `#tour-tab-${value}`, cfg.titleId, cfg.contentId, 'bottom', steps.length === 0));
|
||||
}
|
||||
}
|
||||
if (includeInstallationHint && !isInsideInstallation) {
|
||||
steps.push(makeStep(intl, '#tour-tab-list', 'tourExploreTitle', 'tourExploreContent'));
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
// --- Sodistore Home (product 2) ---
|
||||
|
||||
export const buildSodiohomeCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'live', 'overview', 'information', 'report'
|
||||
], false, inside);
|
||||
|
||||
export const buildSodiohomePartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'information', 'report'
|
||||
], true, inside);
|
||||
|
||||
export const buildSodiohomeAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'manage', 'information', 'configuration', 'history', 'report'
|
||||
], true, inside);
|
||||
|
||||
// --- Salimax (product 0) / Sodistore Max (product 3) ---
|
||||
|
||||
export const buildSalimaxCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'live', 'overview', 'information'
|
||||
], false, inside);
|
||||
|
||||
export const buildSalimaxPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'pvview', 'information'
|
||||
], true, inside);
|
||||
|
||||
export const buildSalimaxAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history', 'pvview'
|
||||
], true, inside);
|
||||
|
||||
// --- Sodistore Grid (product 4) — same as Salimax but no PV View ---
|
||||
|
||||
export const buildSodistoregridCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'live', 'overview', 'information'
|
||||
], false, inside);
|
||||
|
||||
export const buildSodistoregridPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'information'
|
||||
], true, inside);
|
||||
|
||||
export const buildSodistoregridAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history'
|
||||
], true, inside);
|
||||
|
||||
// --- Salidomo (product 1) ---
|
||||
|
||||
export const buildSalidomoCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'batteryview', 'overview', 'information'
|
||||
], false, inside);
|
||||
|
||||
export const buildSalidomoPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'batteryview', 'overview', 'information'
|
||||
], true, inside);
|
||||
|
||||
export const buildSalidomoAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'batteryview', 'overview', 'log', 'manage', 'information', 'history'
|
||||
], true, inside);
|
||||
|
|
@ -87,10 +87,10 @@ function BatteryView(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Unable to communicate with the installation
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -111,10 +111,10 @@ function BatteryView(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Battery service is not available at the moment
|
||||
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="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">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>
|
||||
<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>
|
||||
{product === 0 ? (
|
||||
<TableCell align="center">Warnings</TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
|
||||
) : (
|
||||
<TableCell align="center">Min Cell Voltage</TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="minCellVoltage" defaultMessage="Min Cell Voltage" /></TableCell>
|
||||
)}
|
||||
{product === 0 ? (
|
||||
<TableCell align="center">Alarms</TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
|
||||
) : (
|
||||
<TableCell align="center">Max Cell Voltage</TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="maxCellVoltage" defaultMessage="Max Cell Voltage" /></TableCell>
|
||||
)}
|
||||
{product === 3 && (
|
||||
<TableCell align="center">Voltage Difference</TableCell>
|
||||
{(product === 3 || product === 4) && (
|
||||
<TableCell align="center"><FormattedMessage id="voltageDifference" defaultMessage="Voltage Difference" /></TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
|
@ -469,7 +469,7 @@ function BatteryView(props: BatteryViewProps) {
|
|||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{product === 3 && (
|
||||
{(product === 3 || product === 4) && (
|
||||
<>
|
||||
{(() => {
|
||||
const cellVoltagesString =
|
||||
|
|
@ -524,7 +524,7 @@ function BatteryView(props: BatteryViewProps) {
|
|||
})()}
|
||||
</>
|
||||
)}
|
||||
{product === 3 && (
|
||||
{(product === 3 || product === 4) && (
|
||||
<>
|
||||
{(() => {
|
||||
const cellVoltagesString =
|
||||
|
|
|
|||
|
|
@ -85,10 +85,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Unable to communicate with the installation
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -109,10 +109,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Battery service is not available at the moment
|
||||
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="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">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>
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -38,20 +38,41 @@ 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) => {
|
||||
const index = i + 1; // Battery1, Battery2, ...
|
||||
if (hasDevices) {
|
||||
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
|
||||
const deviceId = String(Math.floor(i / 2) + 1);
|
||||
const batteryIndex = (i % 2) + 1;
|
||||
const device = inverter.Devices[deviceId];
|
||||
|
||||
return {
|
||||
BatteryId: String(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`],
|
||||
}
|
||||
};
|
||||
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,
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
|
|
@ -87,10 +108,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Unable to communicate with the installation
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -111,10 +132,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Battery service is not available at the moment
|
||||
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -195,12 +216,12 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<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"><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">Daily Charge Energy</TableCell>*/}
|
||||
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
|
||||
</TableRow>
|
||||
|
|
@ -219,12 +240,13 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
align="center"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
<Link
|
||||
{/* Detailed battery view commented out */}
|
||||
{/*<Link
|
||||
style={{ color: 'black' }}
|
||||
to={routes.detailed_view + BatteryId}
|
||||
>
|
||||
>*/}
|
||||
{'Battery Cluster ' + BatteryId}
|
||||
</Link>
|
||||
{/*</Link>*/}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -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', // 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>
|
||||
{/* <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>*/}
|
||||
</Grid>
|
||||
|
||||
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ function MainStats(props: MainStatsProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
Fetching data...
|
||||
<FormattedMessage id="fetchingData" defaultMessage="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}>
|
||||
Fetching data...
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -151,22 +151,25 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
pathsToSearch.push('Node' + i);
|
||||
}
|
||||
|
||||
const total = pathsToSearch.length;
|
||||
let i = 0;
|
||||
pathsToSearch.forEach((devicePath) => {
|
||||
if (
|
||||
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
|
||||
chartData[category].data[devicePath].data.length != 0
|
||||
) {
|
||||
// Spread color indices evenly across the palette for better contrast
|
||||
const colorIndex = total <= 1 ? 0 : Math.round(i * 9 / (total - 1));
|
||||
series.push({
|
||||
...chartData[category].data[devicePath],
|
||||
color:
|
||||
color === 'blue'
|
||||
? blueColors[i]
|
||||
? blueColors[colorIndex]
|
||||
: color === 'red'
|
||||
? redColors[i]
|
||||
? redColors[colorIndex]
|
||||
: color === 'green'
|
||||
? greenColors[i]
|
||||
: orangeColors[i]
|
||||
? greenColors[colorIndex]
|
||||
: orangeColors[colorIndex]
|
||||
});
|
||||
}
|
||||
i++;
|
||||
|
|
@ -302,7 +305,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
Fetching data...
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from '@mui/material';
|
||||
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
|
@ -40,6 +40,7 @@ interface ConfigurationProps {
|
|||
}
|
||||
|
||||
function Configuration(props: ConfigurationProps) {
|
||||
const intl = useIntl();
|
||||
if (props.values === null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -137,7 +138,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
props.values.EssControl.Mode === 'CalibrationCharge'
|
||||
) {
|
||||
setDateSelectionError(
|
||||
'You cannot change the date while the installation is in Calibration Charge Mode'
|
||||
intl.formatMessage({ id: 'cannotChangeDateCalibration' })
|
||||
);
|
||||
setErrorDateModalOpen(true);
|
||||
return;
|
||||
|
|
@ -146,7 +147,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
dayjs(formValues.calibrationChargeDate).isBefore(dayjs())
|
||||
) {
|
||||
//console.log('asked for', dayjs(formValues.calibrationChargeDate));
|
||||
setDateSelectionError('You must specify a future date');
|
||||
setDateSelectionError(intl.formatMessage({ id: 'mustSpecifyFutureDate' }));
|
||||
setErrorDateModalOpen(true);
|
||||
return;
|
||||
} else {
|
||||
|
|
@ -458,7 +459,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
helperText={
|
||||
errors.minimumSoC ? (
|
||||
<span style={{ color: 'red' }}>
|
||||
Value should be between 0-100%
|
||||
{intl.formatMessage({ id: 'valueBetween0And100' })}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
|
|
@ -592,7 +593,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
helperText={
|
||||
errors.gridSetPoint ? (
|
||||
<span style={{ color: 'red' }}>
|
||||
Please provide a valid number
|
||||
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
|
|
@ -804,7 +805,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
Successfully applied configuration file
|
||||
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
@ -824,7 +825,7 @@ function Configuration(props: ConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
An error has occurred
|
||||
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ 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);
|
||||
|
|
@ -58,6 +60,8 @@ 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);
|
||||
|
|
@ -109,6 +113,7 @@ function HistoryOfActions(props: HistoryProps) {
|
|||
|
||||
if (res) {
|
||||
getHistory();
|
||||
fetchAllInstallations(product, false);
|
||||
setOpenModalAddAction(false);
|
||||
setEditMode(false);
|
||||
}
|
||||
|
|
@ -129,6 +134,7 @@ function HistoryOfActions(props: HistoryProps) {
|
|||
|
||||
if (res) {
|
||||
getHistory();
|
||||
fetchAllInstallations(product, false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
|
|
@ -14,13 +15,14 @@ import {
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { I_S3Credentials } from '../../../interfaces/S3Types';
|
||||
import { I_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';
|
||||
|
||||
|
|
@ -53,6 +55,24 @@ 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 || formValues.product === 5;
|
||||
|
||||
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({
|
||||
|
|
@ -128,7 +148,7 @@ function Information(props: InformationProps) {
|
|||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 350,
|
||||
width: 450,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
boxShadow: 24,
|
||||
|
|
@ -143,7 +163,37 @@ function Information(props: InformationProps) {
|
|||
gutterBottom
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Do you want to delete this installation?
|
||||
<FormattedMessage
|
||||
id="confirmDeleteInstallation"
|
||||
defaultMessage="Do you want to delete this installation?"
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 1,
|
||||
p: 1,
|
||||
bgcolor: '#f5f5f5',
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
userSelect: 'all',
|
||||
width: '100%',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{props.s3Credentials.s3Bucket}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 1, fontSize: '0.75rem', color: 'text.secondary', textAlign: 'center' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="deleteInstallationWarning"
|
||||
defaultMessage="Please note the bucket name above. Purging S3 data may take several minutes. If deletion fails, you can manually remove the bucket from Exoscale."
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<div
|
||||
|
|
@ -256,6 +306,54 @@ 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={
|
||||
|
|
@ -311,7 +409,7 @@ function Information(props: InformationProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{canEdit && (
|
||||
<>
|
||||
<div>
|
||||
<TextField
|
||||
|
|
@ -328,9 +426,18 @@ function Information(props: InformationProps) {
|
|||
label="S3 Bucket Name"
|
||||
name="s3bucketname"
|
||||
value={
|
||||
product === 0 || product == 3
|
||||
formValues.product === 0 || formValues.product === 3
|
||||
? formValues.s3BucketId +
|
||||
'-3e5b3069-214a-43ee-8d85-57d72000c19d'
|
||||
: formValues.product === 4
|
||||
? formValues.s3BucketId +
|
||||
'-5109c126-e141-43ab-8658-f3c44c838ae8'
|
||||
: formValues.product === 5
|
||||
? formValues.s3BucketId +
|
||||
'-325c9373-9025-4a8d-bf5a-f9eedf1f155c'
|
||||
: formValues.product === 1
|
||||
? formValues.s3BucketId +
|
||||
'-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e'
|
||||
: formValues.s3BucketId +
|
||||
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
|
||||
}
|
||||
|
|
@ -367,7 +474,7 @@ function Information(props: InformationProps) {
|
|||
marginTop: 10
|
||||
}}
|
||||
>
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDelete}
|
||||
|
|
@ -381,7 +488,7 @@ function Information(props: InformationProps) {
|
|||
/>
|
||||
</Button>
|
||||
)}
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{(canEdit || (isPartner && isSodistore)) && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ function InformationSalidomo(props: InformationSalidomoProps) {
|
|||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 350,
|
||||
width: 450,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
boxShadow: 24,
|
||||
|
|
@ -134,7 +134,37 @@ function InformationSalidomo(props: InformationSalidomoProps) {
|
|||
gutterBottom
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Do you want to delete this installation?
|
||||
<FormattedMessage
|
||||
id="confirmDeleteInstallation"
|
||||
defaultMessage="Do you want to delete this installation?"
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 1,
|
||||
p: 1,
|
||||
bgcolor: '#f5f5f5',
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
userSelect: 'all',
|
||||
width: '100%',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{props.s3Credentials.s3Bucket}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 1, fontSize: '0.75rem', color: 'text.secondary', textAlign: 'center' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="deleteInstallationWarning"
|
||||
defaultMessage="Please note the bucket name above. Purging S3 data may take several minutes. If deletion fails, you can manually remove the bucket from Exoscale."
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<div
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,128 @@
|
|||
export const SODIOHOME_DEVICE_TYPES = [
|
||||
{ id: 3, name: 'Growatt' },
|
||||
{ id: 4, name: 'inesco 12K - WR Hybrid' }
|
||||
] as const;
|
||||
|
||||
export const getDeviceTypeName = (deviceId: number): string =>
|
||||
SODIOHOME_DEVICE_TYPES.find(d => d.id === deviceId)?.name ?? '';
|
||||
|
||||
// [inverter][cluster] = batteryCount
|
||||
export type PresetConfig = number[][];
|
||||
|
||||
// 3D array: [inverter][cluster][batteryIndex] = serialNumber
|
||||
export type BatterySnTree = string[][][];
|
||||
|
||||
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = {
|
||||
'sodistore home 9': [[1, 1]],
|
||||
'sodistore home 18': [[2, 2]],
|
||||
'sodistore home 27': [[2, 2], [1, 1]],
|
||||
'sodistore home 36': [[2, 2], [2, 2]],
|
||||
};
|
||||
|
||||
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
|
||||
Array.from({ length: inverterCount }, () => [2, 2]);
|
||||
|
||||
export const parseSodistoreProInverterCount = (model: string): number => {
|
||||
const n = parseInt(model, 10);
|
||||
return isNaN(n) || n < 1 ? 1 : n;
|
||||
};
|
||||
|
||||
export const buildEmptyTree = (preset: PresetConfig): BatterySnTree => {
|
||||
return preset.map((inv) =>
|
||||
inv.map((batteryCount) => Array.from({ length: batteryCount }, () => ''))
|
||||
);
|
||||
};
|
||||
|
||||
export const parseBatterySnTree = (
|
||||
raw: string,
|
||||
preset: PresetConfig
|
||||
): BatterySnTree => {
|
||||
if (!raw || raw.trim() === '') {
|
||||
return buildEmptyTree(preset);
|
||||
}
|
||||
|
||||
const isStructured = raw.includes('/') || raw.includes('|');
|
||||
|
||||
if (isStructured) {
|
||||
const inverters = raw.split('/');
|
||||
return preset.map((invPreset, invIdx) => {
|
||||
const clusterStr = inverters[invIdx] || '';
|
||||
const clusters = clusterStr ? clusterStr.split('|') : [];
|
||||
return invPreset.map((batteryCount, clIdx) => {
|
||||
const batteries = clusters[clIdx]
|
||||
? clusters[clIdx].split(',').map((s) => s.trim())
|
||||
: [];
|
||||
return Array.from({ length: batteryCount }, (_, i) => batteries[i] || '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy flat format: distribute by preset layout
|
||||
const allSns = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
let idx = 0;
|
||||
return preset.map((inv) =>
|
||||
inv.map((batteryCount) =>
|
||||
Array.from({ length: batteryCount }, () => allSns[idx++] || '')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const serializeBatterySnTree = (tree: BatterySnTree): string => {
|
||||
return tree
|
||||
.map((inv) => inv.map((cluster) => cluster.join(',')).join('|'))
|
||||
.join('/');
|
||||
};
|
||||
|
||||
export const remapTree = (
|
||||
oldTree: BatterySnTree,
|
||||
newPreset: PresetConfig
|
||||
): BatterySnTree => {
|
||||
return newPreset.map((inv, invIdx) =>
|
||||
inv.map((batteryCount, clIdx) =>
|
||||
Array.from(
|
||||
{ length: batteryCount },
|
||||
(_, batIdx) => oldTree[invIdx]?.[clIdx]?.[batIdx] || ''
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const computeFlatValues = (
|
||||
preset: PresetConfig,
|
||||
tree: BatterySnTree
|
||||
) => {
|
||||
const totalBatteries = preset.flat().reduce((a, b) => a + b, 0);
|
||||
const totalClusters = preset.reduce((sum, inv) => sum + inv.length, 0);
|
||||
return {
|
||||
batteryNumber: totalBatteries,
|
||||
batteryClusterNumber: totalClusters,
|
||||
batterySerialNumbers: serializeBatterySnTree(tree),
|
||||
};
|
||||
};
|
||||
|
||||
export const wouldLoseData = (
|
||||
oldTree: BatterySnTree,
|
||||
newPreset: PresetConfig
|
||||
): boolean => {
|
||||
for (let invIdx = 0; invIdx < oldTree.length; invIdx++) {
|
||||
for (let clIdx = 0; clIdx < (oldTree[invIdx] || []).length; clIdx++) {
|
||||
for (
|
||||
let batIdx = 0;
|
||||
batIdx < (oldTree[invIdx][clIdx] || []).length;
|
||||
batIdx++
|
||||
) {
|
||||
const sn = oldTree[invIdx][clIdx][batIdx];
|
||||
if (sn && sn.trim() !== '') {
|
||||
if (invIdx >= newPreset.length) return true;
|
||||
if (clIdx >= (newPreset[invIdx] || []).length) return true;
|
||||
const newBatCount = newPreset[invIdx]?.[clIdx] ?? 0;
|
||||
if (batIdx >= newBatCount) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
@ -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 } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
|
|
@ -33,6 +33,7 @@ interface FlatInstallationViewProps {
|
|||
}
|
||||
|
||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||
const intl = useIntl();
|
||||
const [isRowHovered, setHoveredRow] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
|
|
@ -202,7 +203,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
@ -226,7 +227,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByStatus}
|
||||
onChange={(e) => setSortByStatus(e.target.value)}
|
||||
label="Show Only"
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
@ -252,7 +253,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByAction}
|
||||
onChange={(e) => setSortByAction(e.target.value)}
|
||||
label="Show Only"
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import Topology from '../Topology/Topology';
|
|||
import BatteryView from '../BatteryView/BatteryView';
|
||||
import Configuration from '../Configuration/Configuration';
|
||||
import PvView from '../PvView/PvView';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
|
||||
interface singleInstallationProps {
|
||||
current_installation?: I_Installation;
|
||||
|
|
@ -57,9 +58,13 @@ function Installation(props: singleInstallationProps) {
|
|||
s3BucketId: props.current_installation.s3BucketId
|
||||
};
|
||||
|
||||
// TODO: SodistoreGrid — uses its own bucket salt
|
||||
const s3BucketSalt =
|
||||
props.current_installation.product === 4
|
||||
? '5109c126-e141-43ab-8658-f3c44c838ae8'
|
||||
: '3e5b3069-214a-43ee-8d85-57d72000c19d';
|
||||
const s3Bucket =
|
||||
props.current_installation.s3BucketId.toString() +
|
||||
'-3e5b3069-214a-43ee-8d85-57d72000c19d';
|
||||
props.current_installation.s3BucketId.toString() + '-' + s3BucketSalt;
|
||||
|
||||
const s3Credentials = { s3Bucket, ...S3data };
|
||||
|
||||
|
|
@ -196,6 +201,7 @@ 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
|
||||
|
|
@ -213,6 +219,10 @@ function Installation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fetch one time in overview tab to determine connectivity
|
||||
if (currentTab == 'overview') {
|
||||
fetchDataForOneTime();
|
||||
}
|
||||
//Fetch only one time in configuration tab
|
||||
if (currentTab == 'configuration') {
|
||||
fetchDataForOneTime();
|
||||
|
|
@ -372,8 +382,8 @@ function Installation(props: singleInstallationProps) {
|
|||
currentTab != 'information' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'manage' &&
|
||||
currentTab != 'overview' &&
|
||||
currentTab != 'log' && (
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'installationTickets' && (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
|
|
@ -390,7 +400,7 @@ function Installation(props: singleInstallationProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Connecting to the device...
|
||||
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -427,12 +437,15 @@ function Installation(props: singleInstallationProps) {
|
|||
}
|
||||
></Route>
|
||||
|
||||
<Route
|
||||
path={routes.pvview + '/*'}
|
||||
element={
|
||||
<PvView values={values} connected={connected}></PvView>
|
||||
}
|
||||
></Route>
|
||||
{/* TODO: SodistoreGrid — PV View excluded, add back when data path is ready */}
|
||||
{props.current_installation.product !== 4 && (
|
||||
<Route
|
||||
path={routes.pvview + '/*'}
|
||||
element={
|
||||
<PvView values={values} connected={connected}></PvView>
|
||||
}
|
||||
></Route>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={routes.overview}
|
||||
|
|
@ -440,6 +453,8 @@ function Installation(props: singleInstallationProps) {
|
|||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></Overview>
|
||||
}
|
||||
/>
|
||||
|
|
@ -447,11 +462,28 @@ function Installation(props: singleInstallationProps) {
|
|||
<Route
|
||||
path={routes.live}
|
||||
element={
|
||||
<Topology
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></Topology>
|
||||
props.current_installation.product === 4 ? (
|
||||
// TODO: SodistoreGrid — implement actual topology layout
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '40vh'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Live view coming soon
|
||||
</Typography>
|
||||
</Container>
|
||||
) : (
|
||||
<Topology
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></Topology>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -470,10 +502,27 @@ function Installation(props: singleInstallationProps) {
|
|||
<Route
|
||||
path={routes.configuration}
|
||||
element={
|
||||
<Configuration
|
||||
values={values}
|
||||
id={props.current_installation.id}
|
||||
></Configuration>
|
||||
props.current_installation.product === 4 ? (
|
||||
// TODO: SodistoreGrid — implement actual configuration
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '40vh'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Configuration not yet available
|
||||
</Typography>
|
||||
</Container>
|
||||
) : (
|
||||
<Configuration
|
||||
values={values}
|
||||
id={props.current_installation.id}
|
||||
></Configuration>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -503,6 +552,17 @@ function Installation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
path={routes.installationTickets}
|
||||
element={
|
||||
<InstallationTicketsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.live}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,66 @@ 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,
|
||||
|
|
@ -50,7 +110,8 @@ export const fetchDataJson = (
|
|||
|
||||
export const fetchAggregatedDataJson = (
|
||||
date: string,
|
||||
s3Credentials?: I_S3Credentials
|
||||
s3Credentials?: I_S3Credentials,
|
||||
product?: number
|
||||
): Promise<FetchResult<any>> => {
|
||||
const s3Path = `${date}.json`;
|
||||
|
||||
|
|
@ -68,7 +129,12 @@ export const fetchAggregatedDataJson = (
|
|||
if (r.status === 404) {
|
||||
return Promise.resolve(FetchResult.notAvailable);
|
||||
} else if (r.status === 200) {
|
||||
const jsontext = await r.text(); // Assuming the server returns the Base64 encoded ZIP file as text
|
||||
const jsontext = await r.text();
|
||||
|
||||
if (product === 2 || product === 5) {
|
||||
return parseSinexcelAggregatedData(jsontext);
|
||||
}
|
||||
|
||||
const contentEncoding = r.headers.get('content-type');
|
||||
|
||||
if (contentEncoding != 'application/base64; charset=utf-8') {
|
||||
|
|
@ -82,7 +148,6 @@ 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);
|
||||
|
|
@ -94,6 +159,24 @@ export const fetchAggregatedDataJson = (
|
|||
}
|
||||
};
|
||||
|
||||
const parseSinexcelAggregatedData = (jsontext: string): any => {
|
||||
const lines = jsontext.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.Type === 'Daily') {
|
||||
return {
|
||||
PvPower: entry.DailySelfGeneratedElectricity ?? 0,
|
||||
GridImportPower: entry.DailyElectricityPurchased ?? 0,
|
||||
GridExportPower: -(entry.DailyElectricityFed ?? 0),
|
||||
ChargingBatteryPower: entry.BatteryDailyChargeEnergy ?? 0,
|
||||
DischargingBatteryPower: -(entry.BatteryDailyDischargeEnergy ?? 0),
|
||||
LoadPowerConsumption: entry.DailyLoadPowerConsumption ?? 0
|
||||
};
|
||||
}
|
||||
}
|
||||
return FetchResult.notAvailable;
|
||||
};
|
||||
|
||||
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -32,18 +32,26 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
'information',
|
||||
'configuration',
|
||||
'history',
|
||||
'pvview'
|
||||
'pvview',
|
||||
'installationTickets'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
||||
const {
|
||||
salimax_or_sodistore_Installations,
|
||||
sodistoreGridInstallations,
|
||||
fetchAllInstallations,
|
||||
socket,
|
||||
openSocket,
|
||||
closeSocket
|
||||
} = useContext(InstallationsContext);
|
||||
|
||||
// Use the correct installations array based on product
|
||||
const installations =
|
||||
props.product === 4
|
||||
? sodistoreGridInstallations
|
||||
: salimax_or_sodistore_Installations;
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -93,7 +101,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
return ret_path;
|
||||
};
|
||||
|
||||
const singleInstallationTabs =
|
||||
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
|
||||
const hidePvView = props.product === 4;
|
||||
|
||||
const singleInstallationTabs = (
|
||||
currentUser.userType == UserType.admin
|
||||
? [
|
||||
{
|
||||
|
|
@ -155,6 +166,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'pvview',
|
||||
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
|
||||
},
|
||||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -204,7 +219,8 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
<FormattedMessage id="information" defaultMessage="Information" />
|
||||
)
|
||||
}
|
||||
];
|
||||
]
|
||||
).filter((tab) => !(hidePvView && tab.value === 'pvview'));
|
||||
|
||||
const tabs =
|
||||
currentTab != 'list' &&
|
||||
|
|
@ -283,6 +299,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
defaultMessage="History Of Actions"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -372,7 +392,12 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
}
|
||||
];
|
||||
|
||||
return salimax_or_sodistore_Installations.length > 1 ? (
|
||||
// Filter out PV View for SodistoreGrid
|
||||
const filteredTabs = hidePvView
|
||||
? tabs.filter((tab) => tab.value !== 'pvview')
|
||||
: tabs;
|
||||
|
||||
return installations.length > 1 ? (
|
||||
<>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
|
||||
<TabsContainerWrapper>
|
||||
|
|
@ -384,7 +409,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
{filteredTabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
|
|
@ -396,6 +421,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
? routes[tab.value]
|
||||
: navigateToTabPath(location.pathname, routes[tab.value])
|
||||
}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
@ -415,7 +441,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
<Grid item xs={12}>
|
||||
<Box p={4}>
|
||||
<InstallationSearch
|
||||
installations={salimax_or_sodistore_Installations}
|
||||
installations={installations}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -428,6 +454,14 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
element={
|
||||
props.product === 0 ? (
|
||||
<Navigate to={routes.installations + routes.list} />
|
||||
) : props.product === 4 ? (
|
||||
<Navigate
|
||||
to={routes.sodistoregrid_installations + routes.list}
|
||||
/>
|
||||
) : props.product === 5 ? (
|
||||
<Navigate
|
||||
to={routes.sodistorepro_installations + routes.list}
|
||||
/>
|
||||
) : (
|
||||
<Navigate
|
||||
to={routes.sodistore_installations + routes.list}
|
||||
|
|
@ -441,7 +475,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
</Container>
|
||||
<Footer />
|
||||
</>
|
||||
) : salimax_or_sodistore_Installations.length === 1 ? (
|
||||
) : installations.length === 1 ? (
|
||||
<>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
|
||||
<TabsContainerWrapper>
|
||||
|
|
@ -460,6 +494,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
component={Link}
|
||||
label={tab.label}
|
||||
to={routes[tab.value]}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
@ -480,7 +515,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
<Box p={4}>
|
||||
<Installation
|
||||
current_installation={
|
||||
salimax_or_sodistore_Installations[0]
|
||||
installations[0]
|
||||
}
|
||||
type="installation"
|
||||
></Installation>
|
||||
|
|
|
|||
|
|
@ -248,9 +248,9 @@ function Log(props: LogProps) {
|
|||
if (source === 'KnowledgeBase')
|
||||
return <Chip label="Knowledge Base" size="small" sx={{ bgcolor: '#1976d2', color: '#fff', fontWeight: 'bold' }} />;
|
||||
if (source === 'MistralAI')
|
||||
return <Chip label="Mistral AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />;
|
||||
return <Chip label="AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />;
|
||||
if (source === 'MistralFailed')
|
||||
return <Chip label="Mistral failed" size="small" color="error" />;
|
||||
return <Chip label="AI failed" size="small" color="error" />;
|
||||
return <Chip label="Not available" size="small" color="default" />;
|
||||
};
|
||||
|
||||
|
|
@ -259,7 +259,8 @@ function Log(props: LogProps) {
|
|||
<Container maxWidth="xl">
|
||||
<Grid container>
|
||||
|
||||
{/* ── AI Diagnosis Demo Panel ── */}
|
||||
{/* ── AI Diagnosis Demo Panel (commented out — using live AI diagnosis only) ── */}
|
||||
{/*
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
|
|
@ -287,7 +288,7 @@ function Log(props: LogProps) {
|
|||
onChange={e => { setDemoAlarm(e.target.value); setDemoResult(null); }}
|
||||
sx={{ minWidth: 260 }}
|
||||
>
|
||||
<ListSubheader>Sinexcel</ListSubheader>
|
||||
<ListSubheader>inesco 12K - WR Hybrid</ListSubheader>
|
||||
{DEMO_ALARMS.sinexcel.map(a => (
|
||||
<MenuItem key={a} value={a}>{a}</MenuItem>
|
||||
))}
|
||||
|
|
@ -355,6 +356,7 @@ function Log(props: LogProps) {
|
|||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
*/}
|
||||
|
||||
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
|
||||
{diagnosisLoading && (
|
||||
|
|
|
|||
|
|
@ -437,23 +437,76 @@ export interface JSONRecordData {
|
|||
};
|
||||
};
|
||||
|
||||
// For SodistoreHome
|
||||
// For SodistoreHome (Sinexcel multi-inverter structure)
|
||||
InverterRecord: {
|
||||
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;
|
||||
// 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;
|
||||
};
|
||||
|
||||
AcDcGrowatt: {
|
||||
|
|
|
|||
|
|
@ -107,13 +107,15 @@ function UserAccess(props: UserAccessProps) {
|
|||
|
||||
const fetchAvailableInstallations = useCallback(async () => {
|
||||
try {
|
||||
const [res0, res1, res2, res3] = await Promise.all([
|
||||
const [res0, res1, res2, res3, res4, res5] = await Promise.all([
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=0`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=1`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=2`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=3`)
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=3`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=4`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=5`)
|
||||
]);
|
||||
setAvailableInstallations([...res0.data, ...res1.data, ...res2.data, ...res3.data]);
|
||||
setAvailableInstallations([...res0.data, ...res1.data, ...res2.data, ...res3.data, ...res4.data, ...res5.data]);
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 401) removeToken();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
transformInputToDailyDataJson
|
||||
} from 'src/interfaces/Chart';
|
||||
import Button from '@mui/material/Button';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } 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,6 +33,10 @@ import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
|||
interface OverviewProps {
|
||||
s3Credentials: I_S3Credentials;
|
||||
id: number;
|
||||
device?: number;
|
||||
product?: number;
|
||||
connected?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const computeLast7Days = (): string[] => {
|
||||
|
|
@ -52,6 +56,7 @@ const computeLast7Days = (): string[] => {
|
|||
};
|
||||
|
||||
function Overview(props: OverviewProps) {
|
||||
const intl = useIntl();
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
const [dailyData, setDailyData] = useState(true);
|
||||
|
|
@ -102,6 +107,12 @@ function Overview(props: OverviewProps) {
|
|||
}
|
||||
}, [isZooming, dailyDataArray]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.connected === false) {
|
||||
setErrorDateModalOpen(false);
|
||||
}
|
||||
}, [props.connected]);
|
||||
|
||||
useEffect(() => {
|
||||
const resultPromise: Promise<{
|
||||
chartData: chartDataInterface;
|
||||
|
|
@ -119,8 +130,6 @@ 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;
|
||||
}
|
||||
|
|
@ -209,11 +218,19 @@ function Overview(props: OverviewProps) {
|
|||
}> = transformInputToAggregatedDataJson(
|
||||
props.s3Credentials,
|
||||
dayjs().subtract(1, 'week'),
|
||||
dayjs()
|
||||
dayjs(),
|
||||
product
|
||||
);
|
||||
|
||||
resultPromise
|
||||
.then((result) => {
|
||||
if (result.dateList.length === 0) {
|
||||
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||
setErrorDateModalOpen(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const powerDifference = [];
|
||||
for (
|
||||
let i = 0;
|
||||
|
|
@ -288,7 +305,7 @@ 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.');
|
||||
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||
setErrorDateModalOpen(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
|
|
@ -319,11 +336,19 @@ function Overview(props: OverviewProps) {
|
|||
}> = transformInputToAggregatedDataJson(
|
||||
props.s3Credentials,
|
||||
startDate,
|
||||
endDate
|
||||
endDate,
|
||||
product
|
||||
);
|
||||
|
||||
resultPromise
|
||||
.then((result) => {
|
||||
if (result.dateList.length === 0) {
|
||||
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
|
||||
setErrorDateModalOpen(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const powerDifference = [];
|
||||
|
||||
for (
|
||||
|
|
@ -379,6 +404,23 @@ 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
|
||||
|
|
@ -509,6 +551,7 @@ 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"
|
||||
|
|
@ -523,24 +566,23 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||
</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>
|
||||
{props.device !== 3 && props.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}
|
||||
|
|
@ -555,7 +597,6 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="set_date" defaultMessage="Set Date" />
|
||||
</Button>
|
||||
{/*)}*/}
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
|
|
@ -602,6 +643,7 @@ function Overview(props: OverviewProps) {
|
|||
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</>)}
|
||||
|
||||
{loading && (
|
||||
<Container
|
||||
|
|
@ -616,7 +658,7 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
Fetching data...
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -767,7 +809,7 @@ function Overview(props: OverviewProps) {
|
|||
{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.gridExportPower,
|
||||
color: '#ff3333',
|
||||
color: '#2e7d32',
|
||||
type: 'bar'
|
||||
},
|
||||
{
|
||||
|
|
@ -776,13 +818,13 @@ function Overview(props: OverviewProps) {
|
|||
type: 'bar',
|
||||
color: '#ff9900'
|
||||
},
|
||||
{
|
||||
...((product !== 2 && product !== 5) ? [{
|
||||
name: 'Net Energy',
|
||||
color: '#ff3333',
|
||||
color: '#e65100',
|
||||
type: 'line',
|
||||
data: aggregatedDataArray[aggregatedChartState]
|
||||
.netbalance
|
||||
}
|
||||
}] : [])
|
||||
]}
|
||||
height={400}
|
||||
type={'bar'}
|
||||
|
|
@ -799,6 +841,7 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
{!(aggregatedData && (product === 2 || product === 5)) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -890,7 +933,8 @@ function Overview(props: OverviewProps) {
|
|||
)}
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item md={6} xs={12}>
|
||||
)}
|
||||
<Grid item md={(aggregatedData && (product === 2 || product === 5)) ? 12 : 6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -958,11 +1002,14 @@ function Overview(props: OverviewProps) {
|
|||
<ReactApexChart
|
||||
options={{
|
||||
...getChartOptions(
|
||||
aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPower,
|
||||
(product === 2 || product === 5)
|
||||
? aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPowerWithoutHeating
|
||||
: aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPower,
|
||||
'weekly',
|
||||
aggregatedDataArray[aggregatedChartState].datelist,
|
||||
false
|
||||
(product === 2 || product === 5)
|
||||
)
|
||||
}}
|
||||
series={[
|
||||
|
|
@ -971,11 +1018,11 @@ function Overview(props: OverviewProps) {
|
|||
.chartData.dcChargingPower,
|
||||
color: '#008FFB'
|
||||
},
|
||||
{
|
||||
...((product !== 2 && product !== 5) ? [{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.heatingPower,
|
||||
color: '#ff9900'
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.dcDischargingPower,
|
||||
|
|
@ -1027,7 +1074,8 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
<Grid item md={product === 2 ? 12 : 6} xs={12}>
|
||||
{(product !== 2 && product !== 5) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -1088,7 +1136,8 @@ function Overview(props: OverviewProps) {
|
|||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
{product !== 2 && (
|
||||
)}
|
||||
{(product !== 2 && product !== 5) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -1344,6 +1393,63 @@ function Overview(props: OverviewProps) {
|
|||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{aggregatedData && (product === 2 || product === 5) && (
|
||||
<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
|
||||
|
|
@ -1352,7 +1458,7 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
<Grid item md={product === 2 ? 12 : 6} xs={12}>
|
||||
<Grid item md={(product === 2 || product === 5) ? 12 : 6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -1413,7 +1519,7 @@ function Overview(props: OverviewProps) {
|
|||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
{product !== 2 && (
|
||||
{(product !== 2 && product !== 5) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -1485,6 +1591,10 @@ function Overview(props: OverviewProps) {
|
|||
);
|
||||
};
|
||||
|
||||
if (props.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{renderGraphs()}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { UserType } from '../../../interfaces/UserTypes';
|
|||
interface salidomoOverviewProps {
|
||||
s3Credentials: I_S3Credentials;
|
||||
id: number;
|
||||
connected?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const computeLast7Days = (): string[] => {
|
||||
|
|
@ -405,7 +407,7 @@ const computeLast7Days = (): string[] => {
|
|||
// >
|
||||
// <CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
// <Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
// Fetching data...
|
||||
// <FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
// </Typography>
|
||||
// </Container>
|
||||
// )}
|
||||
|
|
@ -750,6 +752,23 @@ 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
|
||||
|
|
@ -874,6 +893,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
</LocalizationProvider>
|
||||
)}
|
||||
<Grid container>
|
||||
{!props.loading && (props.connected !== false || aggregatedDataArray.length > 0) && (<>
|
||||
<Grid item xs={6} md={6}>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -931,6 +951,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
<FormattedMessage id="goback" defaultMessage="Zoom in" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</>)}
|
||||
|
||||
{loading && (
|
||||
<Container
|
||||
|
|
@ -945,7 +966,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
|
||||
Fetching data...
|
||||
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -1123,6 +1144,10 @@ function SalidomoOverview(props: salidomoOverviewProps) {
|
|||
);
|
||||
};
|
||||
|
||||
if (props.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{renderGraphs()}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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;
|
||||
|
|
@ -80,10 +81,10 @@ function PvView(props: PvViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Unable to communicate with the installation
|
||||
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -105,10 +106,10 @@ function PvView(props: PvViewProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Pv view is not available at the moment
|
||||
<FormattedMessage id="pvViewNotAvailable" defaultMessage="Pv view is not available at the moment" />
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
Please wait or refresh the page
|
||||
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -130,10 +131,10 @@ function PvView(props: PvViewProps) {
|
|||
<Table sx={{ minWidth: 250 }} aria-label={`CU${deviceId} table`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">Pv</TableCell>
|
||||
<TableCell align="center">Power</TableCell>
|
||||
<TableCell align="center">Voltage</TableCell>
|
||||
<TableCell align="center">Current</TableCell>
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
useTheme
|
||||
} from '@mui/material';
|
||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
|
@ -32,6 +32,7 @@ interface FlatInstallationViewProps {
|
|||
}
|
||||
|
||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
const currentLocation = useLocation();
|
||||
|
|
@ -182,7 +183,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
@ -206,7 +207,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByStatus}
|
||||
onChange={(e) => setSortByStatus(e.target.value)}
|
||||
label="Show Only"
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
@ -232,7 +233,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<Select
|
||||
value={sortByAction}
|
||||
onChange={(e) => setSortByAction(e.target.value)}
|
||||
label="Show Only"
|
||||
label={intl.formatMessage({ id: 'showOnly' })}
|
||||
>
|
||||
{[
|
||||
'All Installations',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import SalidomoOverview from '../Overview/salidomoOverview';
|
|||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import HistoryOfActions from '../History/History';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
import AccessContextProvider from '../../../contexts/AccessContextProvider';
|
||||
import Access from '../ManageAccess/Access';
|
||||
|
||||
|
|
@ -157,8 +158,31 @@ 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')) {
|
||||
if (location.includes('batteryview') || currentTab == 'overview') {
|
||||
if (location.includes('batteryview') && !location.includes('mainstats')) {
|
||||
if (!continueFetching.current) {
|
||||
continueFetching.current = true;
|
||||
|
|
@ -168,6 +192,10 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fetch one time in overview tab to determine connectivity
|
||||
if (currentTab == 'overview') {
|
||||
fetchDataForOneTime();
|
||||
}
|
||||
|
||||
return () => {
|
||||
continueFetching.current = false;
|
||||
|
|
@ -295,10 +323,10 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'overview' &&
|
||||
currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' && (
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'installationTickets' && (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
|
|
@ -315,7 +343,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Connecting to the device...
|
||||
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -357,6 +385,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
<SalidomoOverview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></SalidomoOverview>
|
||||
}
|
||||
/>
|
||||
|
|
@ -400,6 +430,17 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
path={routes.installationTickets}
|
||||
element={
|
||||
<InstallationTicketsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.batteryview}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
'manage',
|
||||
'overview',
|
||||
'log',
|
||||
'history'
|
||||
'history',
|
||||
'installationTickets'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -136,6 +137,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
defaultMessage="History Of Actions"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -217,6 +222,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
defaultMessage="History Of Actions"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
}
|
||||
]
|
||||
: currentTab != 'list' &&
|
||||
|
|
@ -288,6 +297,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
? routes[tab.value]
|
||||
: navigateToTabPath(location.pathname, routes[tab.value])
|
||||
}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
@ -349,6 +359,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
component={Link}
|
||||
label={tab.label}
|
||||
to={routes[tab.value]}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,565 @@
|
|||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useIntl, FormattedMessage } from 'react-intl';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the Monday of the current week.
|
||||
*/
|
||||
function getCurrentMonday(): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dow = today.getDay(); // 0=Sun
|
||||
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - offset);
|
||||
return monday;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current week Mon→yesterday. Today excluded because
|
||||
* S3 aggregated file is not available until end of day.
|
||||
*/
|
||||
function getCurrentWeekDays(currentMonday: Date): Date[] {
|
||||
const yesterday = new Date();
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const days: Date[] = [];
|
||||
|
||||
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) {
|
||||
days.push(new Date(d));
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────
|
||||
|
||||
export default function DailySection({
|
||||
installationId,
|
||||
onHasData,
|
||||
onPeriodChange
|
||||
}: {
|
||||
installationId: number;
|
||||
onHasData?: (hasData: boolean) => void;
|
||||
onPeriodChange?: (date: string) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
||||
const yesterday = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
|
||||
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState(() => {
|
||||
const date = formatDateISO(yesterday);
|
||||
onPeriodChange?.(date);
|
||||
return date;
|
||||
});
|
||||
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
|
||||
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||
const [loadingWeek, setLoadingWeek] = useState(false);
|
||||
const [noData, setNoData] = useState(false);
|
||||
|
||||
// Current week Mon→yesterday only
|
||||
const weekDays = useMemo(
|
||||
() => getCurrentWeekDays(currentMonday),
|
||||
[currentMonday]
|
||||
);
|
||||
|
||||
// Fetch data for current week days
|
||||
useEffect(() => {
|
||||
if (weekDays.length === 0) return;
|
||||
const from = formatDateISO(weekDays[0]);
|
||||
const to = formatDateISO(weekDays[weekDays.length - 1]);
|
||||
setLoadingWeek(true);
|
||||
axiosConfig
|
||||
.get('/GetDailyDetailRecords', {
|
||||
params: { installationId, from, to }
|
||||
})
|
||||
.then((res) => {
|
||||
const daily = res.data?.dailyRecords?.records ?? [];
|
||||
const hourly = res.data?.hourlyRecords?.records ?? [];
|
||||
setAllRecords(Array.isArray(daily) ? daily : []);
|
||||
setAllHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
||||
onHasData?.(Array.isArray(daily) && daily.length > 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setAllRecords([]);
|
||||
setAllHourlyRecords([]);
|
||||
onHasData?.(false);
|
||||
})
|
||||
.finally(() => setLoadingWeek(false));
|
||||
}, [installationId, weekDays]);
|
||||
|
||||
// When selected date changes, extract data from cache
|
||||
useEffect(() => {
|
||||
setNoData(false);
|
||||
setSelectedDayRecord(null);
|
||||
|
||||
const cachedDay = allRecords.find((r) => r.date === selectedDate);
|
||||
const cachedHours = allHourlyRecords.filter((r) => r.date === selectedDate);
|
||||
|
||||
if (cachedDay) {
|
||||
setSelectedDayRecord(cachedDay);
|
||||
setHourlyRecords(cachedHours);
|
||||
} else if (!loadingWeek) {
|
||||
setNoData(true);
|
||||
setHourlyRecords([]);
|
||||
}
|
||||
}, [installationId, selectedDate, allRecords, allHourlyRecords, loadingWeek]);
|
||||
|
||||
const record = selectedDayRecord;
|
||||
|
||||
const kpis = useMemo(() => computeKPIs(record), [record]);
|
||||
|
||||
const handleStripSelect = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
setNoData(false);
|
||||
onPeriodChange?.(date);
|
||||
};
|
||||
|
||||
const dt = new Date(selectedDate + 'T00:00:00');
|
||||
const dateLabel = dt.toLocaleDateString(intl.locale, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Day Strip — current week Mon→yesterday */}
|
||||
<DayStrip
|
||||
weekDays={weekDays}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={handleStripSelect}
|
||||
/>
|
||||
|
||||
{/* Loading state */}
|
||||
{loadingWeek && !record && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
py: 6
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* No data state */}
|
||||
{!loadingWeek && noData && !record && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
<Alert severity="warning">
|
||||
<FormattedMessage
|
||||
id="noReportData"
|
||||
defaultMessage="No report data found."
|
||||
/>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 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,
|
||||
selectedDate,
|
||||
onSelect,
|
||||
}: {
|
||||
weekDays: Date[];
|
||||
selectedDate: string;
|
||||
onSelect: (date: string) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
overflowX: 'auto',
|
||||
pb: 1
|
||||
}}
|
||||
>
|
||||
{weekDays.map((day) => {
|
||||
const dateStr = formatDateISO(day);
|
||||
const isSelected = dateStr === selectedDate;
|
||||
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>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||
<FormattedMessage
|
||||
id="currentWeekHint"
|
||||
defaultMessage="Current week (Mon–yesterday)"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,15 +19,18 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||
import routes from '../../../Resources/routes.json';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import { getDeviceTypeName } from '../Information/installationSetupUtils';
|
||||
|
||||
interface FlatInstallationViewProps {
|
||||
installations: I_Installation[];
|
||||
product?: number;
|
||||
}
|
||||
|
||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
const currentLocation = useLocation();
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
//
|
||||
const sortedInstallations = [...props.installations].sort((a, b) => {
|
||||
// Compare the status field of each installation and sort them based on the status.
|
||||
|
|
@ -50,7 +53,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
setSelectedInstallation(-1);
|
||||
|
||||
navigate(
|
||||
routes.sodiohome_installations +
|
||||
baseRoute +
|
||||
routes.list +
|
||||
routes.installation +
|
||||
`${installationID}` +
|
||||
|
|
@ -81,9 +84,9 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
sx={{
|
||||
display:
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + 'list' ||
|
||||
baseRoute + 'list' ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + routes.list
|
||||
baseRoute + routes.list
|
||||
? 'block'
|
||||
: 'none'
|
||||
}}
|
||||
|
|
@ -96,14 +99,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<TableCell>
|
||||
<FormattedMessage id="name" defaultMessage="Name" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="location" defaultMessage="Location" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="region" defaultMessage="Region" />
|
||||
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="canton" defaultMessage="Canton" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="country" defaultMessage="Country" />
|
||||
|
|
@ -146,19 +149,6 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color="text.primary"
|
||||
gutterBottom
|
||||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.location}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
|
@ -181,7 +171,20 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.region}
|
||||
{getDeviceTypeName(installation.device)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color="text.primary"
|
||||
gutterBottom
|
||||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.canton || ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
|||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||
import Overview from '../Overview/overview';
|
||||
import WeeklyReport from './WeeklyReport';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
|
||||
interface singleInstallationProps {
|
||||
current_installation?: I_Installation;
|
||||
|
|
@ -49,7 +50,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
const s3Bucket =
|
||||
props.current_installation.s3BucketId.toString() +
|
||||
'-' +
|
||||
'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa';
|
||||
(props.current_installation.product === 5
|
||||
? '325c9373-9025-4a8d-bf5a-f9eedf1f155c'
|
||||
: 'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa');
|
||||
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
|
|
@ -111,20 +114,52 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
const fetchDataPeriodically = async () => {
|
||||
var timeperiodToSearch = 200;
|
||||
let res;
|
||||
let timestampToFetch;
|
||||
// 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;
|
||||
});
|
||||
|
||||
for (var i = 0; i < timeperiodToSearch; i += 2) {
|
||||
const results = await Promise.all(promises);
|
||||
// Return the most recent hit (smallest offset = first in array)
|
||||
return results.find((r) => r !== null) || null;
|
||||
};
|
||||
|
||||
const fetchDataPeriodically = async () => {
|
||||
let res;
|
||||
let timestampToFetch: UnixTime;
|
||||
|
||||
// Search backward in parallel batches of 10 timestamps (2s apart)
|
||||
// Each batch covers 20 seconds, so 20 batches cover 400 seconds
|
||||
const batchSize = 10;
|
||||
const step = 2; // 2-second steps to match even-rounding granularity
|
||||
const maxAge = 400;
|
||||
let found = false;
|
||||
|
||||
for (let batchStart = 0; batchStart < maxAge; batchStart += batchSize * step) {
|
||||
if (!continueFetching.current) {
|
||||
return false;
|
||||
}
|
||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
||||
const offsets = [];
|
||||
for (let j = 0; j < batchSize; j++) {
|
||||
const offset = batchStart + j * step;
|
||||
if (offset < maxAge) offsets.push(offset);
|
||||
}
|
||||
|
||||
try {
|
||||
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
|
||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
||||
const hit = await probeTimestampBatch(offsets);
|
||||
if (hit) {
|
||||
res = hit.res;
|
||||
timestampToFetch = hit.timestamp;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -133,7 +168,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (i >= timeperiodToSearch) {
|
||||
if (!found) {
|
||||
setConnected(false);
|
||||
setLoading(false);
|
||||
return false;
|
||||
|
|
@ -154,10 +189,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
await timeout(2000);
|
||||
}
|
||||
|
||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(60));
|
||||
// Advance by 150s to find the next chunk (15 records × 10s interval)
|
||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(150));
|
||||
console.log('NEW TIMESTAMP TO FETCH IS ' + timestampToFetch);
|
||||
|
||||
for (i = 0; i < 30; i++) {
|
||||
let foundNext = false;
|
||||
for (var i = 0; i < 60; i++) {
|
||||
if (!continueFetching.current) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -169,6 +206,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
res !== FetchResult.notAvailable &&
|
||||
res !== FetchResult.tryLater
|
||||
) {
|
||||
foundNext = true;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -177,24 +215,30 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(1));
|
||||
}
|
||||
if (i == 30) {
|
||||
if (!foundNext) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDataForOneTime = async () => {
|
||||
var timeperiodToSearch = 300; // 5 minutes to cover ~4 upload cycles
|
||||
// Search backward in parallel batches of 10 timestamps (2s apart)
|
||||
const batchSize = 10;
|
||||
const step = 2;
|
||||
const maxAge = 400;
|
||||
let res;
|
||||
let timestampToFetch;
|
||||
|
||||
// Search from NOW backward to find the most recent data
|
||||
// Step by 10 seconds - balances between finding files quickly and reducing 404s
|
||||
for (var i = 0; i < timeperiodToSearch; i += 10) {
|
||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
|
||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
||||
const hit = await probeTimestampBatch(offsets);
|
||||
if (hit) {
|
||||
res = hit.res;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -203,11 +247,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (i >= timeperiodToSearch) {
|
||||
if (!res) {
|
||||
setConnected(false);
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setConnected(true);
|
||||
setLoading(false);
|
||||
|
||||
|
|
@ -215,12 +260,6 @@ 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;
|
||||
};
|
||||
|
||||
|
|
@ -240,6 +279,7 @@ 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
|
||||
|
|
@ -257,14 +297,20 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fetch periodically in configuration tab (every 30 seconds to detect S3 updates)
|
||||
// Fetch one time in overview tab to determine connectivity
|
||||
if (currentTab == 'overview') {
|
||||
fetchDataForOneTime();
|
||||
return () => {
|
||||
continueFetching.current = false;
|
||||
};
|
||||
}
|
||||
// Fetch periodically in configuration tab to detect S3 config updates
|
||||
if (currentTab == 'configuration') {
|
||||
fetchDataForOneTime(); // Initial fetch
|
||||
fetchDataForOneTime();
|
||||
|
||||
const configRefreshInterval = setInterval(() => {
|
||||
console.log('Refreshing configuration data from S3...');
|
||||
fetchDataForOneTime();
|
||||
}, 15000); // Refresh every 15 seconds
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
continueFetching.current = false;
|
||||
|
|
@ -340,11 +386,10 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{props.current_installation.device === 4
|
||||
? values.InverterRecord?.WorkingMode
|
||||
: props.current_installation.device === 3
|
||||
? values.InverterRecord?.OperatingMode
|
||||
: values.Config.OperatingPriority}
|
||||
{values.InverterRecord?.OperatingPriority
|
||||
?? values.InverterRecord?.WorkingMode
|
||||
?? values.InverterRecord?.OperatingMode
|
||||
?? values.Config?.OperatingPriority}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -428,11 +473,11 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'overview' &&
|
||||
currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'report' && (
|
||||
currentTab != 'report' &&
|
||||
currentTab != 'installationTickets' && (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
|
|
@ -449,7 +494,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
Connecting to the device...
|
||||
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
|
@ -559,18 +604,35 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
product={props.current_installation.product}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={routes.report}
|
||||
element={
|
||||
<WeeklyReport
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{props.current_installation.device !== 3 && (
|
||||
<Route
|
||||
path={routes.report}
|
||||
element={
|
||||
<WeeklyReport
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
path={routes.installationTickets}
|
||||
element={
|
||||
<InstallationTicketsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
|
@ -9,11 +10,14 @@ import SodioHomeInstallation from './Installation';
|
|||
|
||||
interface installationSearchProps {
|
||||
installations: I_Installation[];
|
||||
product?: number;
|
||||
}
|
||||
|
||||
function InstallationSearch(props: installationSearchProps) {
|
||||
const intl = useIntl();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const currentLocation = useLocation();
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
// const [filteredData, setFilteredData] = useState(props.installations);
|
||||
|
||||
const indexedData = useMemo(() => {
|
||||
|
|
@ -44,9 +48,9 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
sx={{
|
||||
display:
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + 'list' ||
|
||||
baseRoute + 'list' ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + routes.list
|
||||
baseRoute + routes.list
|
||||
? 'block'
|
||||
: 'none'
|
||||
}}
|
||||
|
|
@ -60,7 +64,7 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
>
|
||||
<FormControl variant="outlined">
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
|
|
@ -77,7 +81,7 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<FlatInstallationView installations={filteredData} />
|
||||
<FlatInstallationView installations={filteredData} product={props.product} />
|
||||
<Routes>
|
||||
{filteredData.map((installation) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from '@mui/material';
|
||||
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
|
@ -39,6 +39,7 @@ interface SodistoreHomeConfigurationProps {
|
|||
}
|
||||
|
||||
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||
const intl = useIntl();
|
||||
if (props.values === null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -46,19 +47,16 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
const device = props.installation.device;
|
||||
|
||||
const OperatingPriorityOptions =
|
||||
device === 3 // Growatt
|
||||
device === 3 || device === 4
|
||||
? ['LoadPriority', 'BatteryPriority', 'GridPriority']
|
||||
: device === 4 // Sinexcel
|
||||
? [
|
||||
'SpontaneousSelfUse',
|
||||
'TimeChargeDischarge',
|
||||
// 'TimeOfUsePowerPrice',
|
||||
// 'DisasterStandby',
|
||||
// 'ManualControl',
|
||||
'PvPriorityCharging',
|
||||
// 'PrioritySellElectricity'
|
||||
]
|
||||
: [];
|
||||
: [];
|
||||
|
||||
// Sinexcel S3 stores WorkingMode enum names — map them to Growatt-style display names
|
||||
const sinexcelS3ToDisplayName: Record<string, string> = {
|
||||
'SpontaneousSelfUse': 'LoadPriority',
|
||||
'TimeChargeDischarge': 'BatteryPriority',
|
||||
'PvPriorityCharging': 'GridPriority',
|
||||
};
|
||||
|
||||
const [errors, setErrors] = useState({
|
||||
minimumSoC: false,
|
||||
|
|
@ -81,99 +79,117 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
const { currentUser, setUser } = context;
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
|
||||
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",
|
||||
});
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Storage key for pending config (optimistic update)
|
||||
const pendingConfigKey = `pendingConfig_${props.id}`;
|
||||
|
||||
// Helper to get current S3 values
|
||||
const getS3Values = () => ({
|
||||
// Helper to build form values from S3 data
|
||||
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||
minimumSoC: props.values.Config.MinSoc,
|
||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||
operatingPriority: OperatingPriorityOptions.indexOf(
|
||||
operatingPriority: resolveOperatingPriorityIndex(
|
||||
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,
|
||||
startTimeChargeandDischargeDayandTime:
|
||||
props.values.Config?.StartTimeChargeandDischargeDayandTime
|
||||
? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate()
|
||||
: null,
|
||||
stopTimeChargeandDischargeDayandTime:
|
||||
props.values.Config?.StopTimeChargeandDischargeDayandTime
|
||||
? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate()
|
||||
: null,
|
||||
startTimeChargeandDischargeDayandTime: (() => {
|
||||
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
|
||||
const parsed = raw ? dayjs(raw) : null;
|
||||
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||
})(),
|
||||
stopTimeChargeandDischargeDayandTime: (() => {
|
||||
const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime;
|
||||
const parsed = raw ? dayjs(raw) : null;
|
||||
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||
})(),
|
||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||
});
|
||||
|
||||
// Sync form values when props.values changes
|
||||
// Logic: Use localStorage only briefly after submit to prevent flicker, then trust S3
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
const s3Values = getS3Values();
|
||||
const pendingConfigStr = localStorage.getItem(pendingConfigKey);
|
||||
const pending = restorePendingConfig();
|
||||
|
||||
if (pendingConfigStr) {
|
||||
try {
|
||||
const pendingConfig = JSON.parse(pendingConfigStr);
|
||||
const submittedAt = pendingConfig.submittedAt || 0;
|
||||
const timeSinceSubmit = Date.now() - submittedAt;
|
||||
|
||||
// Within 150 seconds of submit: use localStorage (waiting for S3 sync)
|
||||
// This covers two full S3 upload cycles (75 sec × 2) to ensure new file is available
|
||||
if (timeSinceSubmit < 150000) {
|
||||
// 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) {
|
||||
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
|
||||
localStorage.removeItem(pendingConfigKey);
|
||||
setFormValues(s3Values);
|
||||
} else {
|
||||
// S3 still has same data as at submit time — keep showing pending values
|
||||
setFormValues(pending.values);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No localStorage or expired: always use S3 (source of truth)
|
||||
// No pending config — trust S3 (source of truth)
|
||||
setFormValues(s3Values);
|
||||
}, [props.values]);
|
||||
|
||||
|
|
@ -186,9 +202,11 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
});
|
||||
};
|
||||
|
||||
// Add time validation function
|
||||
// Add time validation function — only relevant for Sinexcel BatteryPriority
|
||||
const validateTimeOnly = () => {
|
||||
if (formValues.startTimeChargeandDischargeDayandTime &&
|
||||
if (device === 4 &&
|
||||
OperatingPriorityOptions[formValues.operatingPriority] === 'BatteryPriority' &&
|
||||
formValues.startTimeChargeandDischargeDayandTime &&
|
||||
formValues.stopTimeChargeandDischargeDayandTime) {
|
||||
const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours();
|
||||
const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes();
|
||||
|
|
@ -199,7 +217,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
const stopTimeInMinutes = stopHours * 60 + stopMinutes;
|
||||
|
||||
if (startTimeInMinutes >= stopTimeInMinutes) {
|
||||
setDateSelectionError('Stop time must be later than start time');
|
||||
setDateSelectionError(intl.formatMessage({ id: 'stopTimeMustBeLater' }));
|
||||
setErrorDateModalOpen(true);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -246,12 +264,15 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
setUpdated(true);
|
||||
setLoading(false);
|
||||
|
||||
// Save submitted values to localStorage for optimistic UI update
|
||||
// This ensures the form shows correct values even before S3 syncs (up to 75 sec delay)
|
||||
localStorage.setItem(pendingConfigKey, JSON.stringify({
|
||||
// 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 = {
|
||||
values: formValues,
|
||||
submittedAt: Date.now()
|
||||
}));
|
||||
submittedAt: Date.now(),
|
||||
s3ConfigSnapshot: getS3ConfigFingerprint(),
|
||||
};
|
||||
localStorage.setItem(pendingConfigKey, JSON.stringify(cachePayload));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -459,7 +480,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
{/* fullWidth*/}
|
||||
{/*/>*/}
|
||||
<TextField
|
||||
label="Minimum SoC (%)"
|
||||
label={intl.formatMessage({ id: 'minimumSocPercent' })}
|
||||
name="minimumSoC"
|
||||
value={formValues.minimumSoC}
|
||||
onChange={handleChange}
|
||||
|
|
@ -529,21 +550,21 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
</div>
|
||||
</>
|
||||
|
||||
{/* --- Sinexcel + TimeChargeDischarge --- */}
|
||||
{/* --- Sinexcel + BatteryPriority (maps to TimeChargeDischarge on device) --- */}
|
||||
{device === 4 &&
|
||||
OperatingPriorityOptions[formValues.operatingPriority] ===
|
||||
'TimeChargeDischarge' && (
|
||||
'BatteryPriority' && (
|
||||
<>
|
||||
{/* Power input*/}
|
||||
<div style={{ marginBottom: '5px' }}>
|
||||
<TextField
|
||||
label="Power (W)"
|
||||
label={intl.formatMessage({ id: 'powerW' })}
|
||||
name="timeChargeandDischargePower"
|
||||
value={formValues.timeChargeandDischargePower}
|
||||
onChange={(e) =>
|
||||
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
||||
}
|
||||
helperText="Enter a positive or negative power value"
|
||||
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -553,7 +574,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DateTimePicker
|
||||
ampm={false}
|
||||
label="Start Date and Time (Start Time < Stop Time)"
|
||||
label={intl.formatMessage({ id: 'startDateTime' })}
|
||||
value={
|
||||
formValues.startTimeChargeandDischargeDayandTime
|
||||
? dayjs(formValues.startTimeChargeandDischargeDayandTime)
|
||||
|
|
@ -585,7 +606,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DateTimePicker
|
||||
ampm={false}
|
||||
label="Stop Date and Time (Start Time < Stop Time)"
|
||||
label={intl.formatMessage({ id: 'stopDateTime' })}
|
||||
value={
|
||||
formValues.stopTimeChargeandDischargeDayandTime
|
||||
? dayjs(formValues.stopTimeChargeandDischargeDayandTime)
|
||||
|
|
@ -651,7 +672,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
Successfully applied configuration file
|
||||
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
@ -671,7 +692,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
An error has occurred
|
||||
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -17,29 +17,32 @@ import { Close as CloseIcon } from '@mui/icons-material';
|
|||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { INSTALLATION_PRESETS, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
|
||||
|
||||
interface SodistorehomeInstallationFormPros {
|
||||
cancel: () => void;
|
||||
submit: () => void;
|
||||
parentid: number;
|
||||
product?: number;
|
||||
}
|
||||
|
||||
function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(true);
|
||||
const isSodistorePro = props.product === 5;
|
||||
const [formValues, setFormValues] = useState<Partial<I_Installation>>({
|
||||
name: '',
|
||||
region: '',
|
||||
location: '',
|
||||
country: '',
|
||||
vpnIp: '',
|
||||
installationModel: '',
|
||||
externalEms: 'No',
|
||||
...(isSodistorePro ? { device: 4 } : {}),
|
||||
});
|
||||
const requiredFields = ['name', 'location', 'country', 'vpnIp'];
|
||||
const [inverterCount, setInverterCount] = useState('');
|
||||
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['installationModel'])];
|
||||
|
||||
const DeviceTypes = [
|
||||
{ id: 3, name: 'Growatt' },
|
||||
{ id: 4, name: 'Sinexcel' }
|
||||
];
|
||||
const DeviceTypes = isSodistorePro
|
||||
? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
|
||||
: SODIOHOME_DEVICE_TYPES;
|
||||
const installationContext = useContext(InstallationsContext);
|
||||
const { createInstallation, loading, setLoading, error, setError } =
|
||||
installationContext;
|
||||
|
|
@ -55,7 +58,10 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
const handleSubmit = async (e) => {
|
||||
setLoading(true);
|
||||
formValues.parentId = props.parentid;
|
||||
formValues.product = 2;
|
||||
formValues.product = props.product ?? 2;
|
||||
if (isSodistorePro) {
|
||||
formValues.installationModel = inverterCount;
|
||||
}
|
||||
const responseData = await createInstallation(formValues);
|
||||
props.submit();
|
||||
};
|
||||
|
|
@ -69,6 +75,9 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
return false;
|
||||
}
|
||||
}
|
||||
if (isSodistorePro && (!inverterCount || parseInt(inverterCount, 10) < 1)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -124,42 +133,6 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
error={formValues.name === ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="region" defaultMessage="Region" />}
|
||||
name="region"
|
||||
value={formValues.region}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={formValues.region === ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage id="location" defaultMessage="Location" />
|
||||
}
|
||||
name="location"
|
||||
value={formValues.location}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={formValues.location === ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage id="country" defaultMessage="Country" />
|
||||
}
|
||||
name="country"
|
||||
value={formValues.country}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={formValues.country === ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="VpnIp" defaultMessage="VpnIp" />}
|
||||
|
|
@ -171,6 +144,67 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isSodistorePro ? (
|
||||
<div>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="numberOfInverters"
|
||||
defaultMessage="Number of Inverters"
|
||||
/>
|
||||
}
|
||||
name="inverterCount"
|
||||
type="text"
|
||||
value={inverterCount}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '' || (/^\d+$/.test(val) && parseInt(val, 10) <= 20)) {
|
||||
setInverterCount(val);
|
||||
}
|
||||
}}
|
||||
required
|
||||
error={!inverterCount || parseInt(inverterCount, 10) < 1}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FormControl
|
||||
fullWidth
|
||||
required
|
||||
error={formValues.installationModel === ''}
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
width: 390
|
||||
}}
|
||||
>
|
||||
<InputLabel
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="installationModel"
|
||||
defaultMessage="Installation Model"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="installationModel"
|
||||
value={formValues.installationModel || ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSodistorePro && (
|
||||
<div>
|
||||
<FormControl
|
||||
fullWidth
|
||||
|
|
@ -204,6 +238,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
<div
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,6 +13,8 @@ import TreeView from '../Tree/treeView';
|
|||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import SodioHomeInstallation from './Installation';
|
||||
import AcknowledgementDialog, { CURRENT_TERMS_VERSION } from '../../../components/AcknowledgementDialog';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
|
||||
interface SodioHomeInstallationTabsProps {
|
||||
product: number;
|
||||
|
|
@ -21,7 +23,25 @@ interface SodioHomeInstallationTabsProps {
|
|||
function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||
const location = useLocation();
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
const { currentUser, setUser } = context;
|
||||
|
||||
const showTermsDialog =
|
||||
currentUser?.acknowledgedTermsVersion == null ||
|
||||
currentUser.acknowledgedTermsVersion < CURRENT_TERMS_VERSION;
|
||||
|
||||
const handleAcknowledgeTerms = () => {
|
||||
axiosConfig
|
||||
.put('/AcknowledgeTerms', undefined, {
|
||||
params: { version: CURRENT_TERMS_VERSION }
|
||||
})
|
||||
.then(() => {
|
||||
const updatedUser = {
|
||||
...currentUser,
|
||||
acknowledgedTermsVersion: CURRENT_TERMS_VERSION
|
||||
};
|
||||
setUser(updatedUser);
|
||||
});
|
||||
};
|
||||
const tabList = [
|
||||
'live',
|
||||
'overview',
|
||||
|
|
@ -31,7 +51,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
'log',
|
||||
'history',
|
||||
'configuration',
|
||||
'report'
|
||||
'report',
|
||||
'installationTickets'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -39,12 +60,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
useState<boolean>(false);
|
||||
const {
|
||||
sodiohomeInstallations,
|
||||
sodistoreProInstallations,
|
||||
fetchAllInstallations,
|
||||
socket,
|
||||
openSocket,
|
||||
closeSocket
|
||||
} = useContext(InstallationsContext);
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
const installations = props.product === 5 ? sodistoreProInstallations : sodiohomeInstallations;
|
||||
|
||||
useEffect(() => {
|
||||
let path = location.pathname.split('/');
|
||||
|
|
@ -159,6 +183,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
defaultMessage="Report"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -231,6 +259,13 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
currentTab != 'tree' &&
|
||||
!location.pathname.includes('folder');
|
||||
|
||||
// Determine if current installation is Growatt (device=3) to hide report tab
|
||||
const currentInstallation = installations.find((i) =>
|
||||
location.pathname.includes(`/${i.id}/`)
|
||||
);
|
||||
const isGrowatt = currentInstallation?.device === 3
|
||||
|| (installations.length === 1 && installations[0].device === 3);
|
||||
|
||||
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
||||
? [
|
||||
{
|
||||
|
|
@ -303,6 +338,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
defaultMessage="Report"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
}
|
||||
]
|
||||
: inInstallationView && currentUser.userType == UserType.partner
|
||||
|
|
@ -397,8 +436,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
}
|
||||
];
|
||||
|
||||
return sodiohomeInstallations.length > 1 ? (
|
||||
return installations.length > 1 ? (
|
||||
<>
|
||||
<AcknowledgementDialog
|
||||
open={showTermsDialog}
|
||||
onAcknowledge={handleAcknowledgeTerms}
|
||||
/>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
|
||||
<TabsContainerWrapper>
|
||||
<Tabs
|
||||
|
|
@ -409,13 +452,16 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
{tabs
|
||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||
.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
icon={tab.icon}
|
||||
component={Link}
|
||||
label={tab.label}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
to={
|
||||
tab.value === 'list' || tab.value === 'tree'
|
||||
? routes[tab.value]
|
||||
|
|
@ -440,7 +486,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
<Grid item xs={12}>
|
||||
<Box p={4}>
|
||||
<InstallationSearch
|
||||
installations={sodiohomeInstallations}
|
||||
installations={installations}
|
||||
product={props.product}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -453,7 +500,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
path={'*'}
|
||||
element={
|
||||
<Navigate
|
||||
to={routes.sodiohome_installations + routes.list}
|
||||
to={baseRoute + routes.list}
|
||||
></Navigate>
|
||||
}
|
||||
></Route>
|
||||
|
|
@ -462,9 +509,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
</Card>
|
||||
</Container>
|
||||
</>
|
||||
) : sodiohomeInstallations.length === 1 ? (
|
||||
) : installations.length === 1 ? (
|
||||
<>
|
||||
{' '}
|
||||
<AcknowledgementDialog
|
||||
open={showTermsDialog}
|
||||
onAcknowledge={handleAcknowledgeTerms}
|
||||
/>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
|
||||
<TabsContainerWrapper>
|
||||
<Tabs
|
||||
|
|
@ -475,13 +525,16 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
{singleInstallationTabs.map((tab) => (
|
||||
{singleInstallationTabs
|
||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||
.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
component={Link}
|
||||
label={tab.label}
|
||||
to={routes[tab.value]}
|
||||
id={`tour-tab-${tab.value}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
|
@ -501,7 +554,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
<Grid item xs={12}>
|
||||
<Box p={4}>
|
||||
<SodioHomeInstallation
|
||||
current_installation={sodiohomeInstallations[0]}
|
||||
current_installation={installations[0]}
|
||||
type="installation"
|
||||
></SodioHomeInstallation>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
|
||||
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import {
|
||||
DiagnosisStatus,
|
||||
DiagnosisFeedback,
|
||||
TicketAiDiagnosis
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
|
||||
interface AiDiagnosisPanelProps {
|
||||
diagnosis: TicketAiDiagnosis | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function getConfidenceColor(
|
||||
confidence: number
|
||||
): 'success' | 'warning' | 'error' {
|
||||
if (confidence >= 0.7) return 'success';
|
||||
if (confidence >= 0.4) return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function parseActions(actionsJson: string | null): string[] {
|
||||
if (!actionsJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(actionsJson);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return actionsJson.split('\n').filter((s) => s.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const feedbackLabels: Record<number, string> = {
|
||||
[DiagnosisFeedback.Accepted]: 'Accepted',
|
||||
[DiagnosisFeedback.Rejected]: 'Rejected',
|
||||
[DiagnosisFeedback.Overridden]: 'Overridden'
|
||||
};
|
||||
|
||||
function AiDiagnosisPanel({ diagnosis, onRefresh }: AiDiagnosisPanelProps) {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [submittingFeedback, setSubmittingFeedback] = useState(false);
|
||||
|
||||
const isPending =
|
||||
diagnosis &&
|
||||
(diagnosis.status === DiagnosisStatus.Pending ||
|
||||
diagnosis.status === DiagnosisStatus.Analyzing);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
intervalRef.current = setInterval(onRefresh, 5000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isPending, onRefresh]);
|
||||
|
||||
const handleFeedback = (feedback: DiagnosisFeedback) => {
|
||||
if (!diagnosis) return;
|
||||
setSubmittingFeedback(true);
|
||||
axiosConfig
|
||||
.post('/SubmitDiagnosisFeedback', null, {
|
||||
params: {
|
||||
ticketId: diagnosis.ticketId,
|
||||
feedback
|
||||
}
|
||||
})
|
||||
.then(() => onRefresh())
|
||||
.finally(() => setSubmittingFeedback(false));
|
||||
};
|
||||
|
||||
if (!diagnosis) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="aiDiagnosis"
|
||||
defaultMessage="AI Diagnosis"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="noDiagnosis"
|
||||
defaultMessage="No AI diagnosis available."
|
||||
/>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="aiDiagnosis"
|
||||
defaultMessage="AI Diagnosis"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="diagnosisAnalyzing"
|
||||
defaultMessage="AI is analyzing this ticket..."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (diagnosis.status === DiagnosisStatus.Failed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="aiDiagnosis"
|
||||
defaultMessage="AI Diagnosis"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Alert severity="error">
|
||||
<FormattedMessage
|
||||
id="diagnosisFailed"
|
||||
defaultMessage="AI diagnosis failed. Please try again later."
|
||||
/>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const actions = parseActions(diagnosis.recommendedActions);
|
||||
const hasFeedback = diagnosis.feedback != null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage id="aiDiagnosis" defaultMessage="AI Diagnosis" />
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<FormattedMessage id="rootCause" defaultMessage="Root Cause" />
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{diagnosis.rootCause ?? '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<FormattedMessage id="confidence" defaultMessage="Confidence" />
|
||||
</Typography>
|
||||
{diagnosis.confidence != null ? (
|
||||
<Chip
|
||||
label={`${Math.round(diagnosis.confidence * 100)}%`}
|
||||
color={getConfidenceColor(diagnosis.confidence)}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2">-</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{actions.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="recommendedActions"
|
||||
defaultMessage="Recommended Actions"
|
||||
/>
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{actions.map((action, i) => (
|
||||
<ListItem key={i} disableGutters>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
fontSize="small"
|
||||
color="primary"
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={action} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{hasFeedback ? (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="feedbackSubmitted"
|
||||
defaultMessage="Feedback: {feedback}"
|
||||
values={{
|
||||
feedback: feedbackLabels[diagnosis.feedback!] ?? 'Unknown'
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2">
|
||||
<FormattedMessage
|
||||
id="diagnosisFeedbackLabel"
|
||||
defaultMessage="Was this helpful?"
|
||||
/>
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
startIcon={<ThumbUpIcon />}
|
||||
disabled={submittingFeedback}
|
||||
onClick={() => handleFeedback(DiagnosisFeedback.Accepted)}
|
||||
>
|
||||
<FormattedMessage id="accept" defaultMessage="Accept" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<ThumbDownIcon />}
|
||||
disabled={submittingFeedback}
|
||||
onClick={() => handleFeedback(DiagnosisFeedback.Rejected)}
|
||||
>
|
||||
<FormattedMessage id="reject" defaultMessage="Reject" />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiDiagnosisPanel;
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Divider,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||
|
||||
interface CommentThreadProps {
|
||||
ticketId: number;
|
||||
comments: TicketComment[];
|
||||
onCommentAdded: () => void;
|
||||
adminUsers?: AdminUser[];
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
ticketId,
|
||||
comments,
|
||||
onCommentAdded,
|
||||
adminUsers = []
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const sorted = [...comments].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!body.trim()) return;
|
||||
setSubmitting(true);
|
||||
axiosConfig
|
||||
.post('/AddTicketComment', { ticketId, body })
|
||||
.then(() => {
|
||||
setBody('');
|
||||
onCommentAdded();
|
||||
})
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage id="comments" defaultMessage="Comments" />
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{sorted.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="noComments"
|
||||
defaultMessage="No comments yet."
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{sorted.map((comment) => {
|
||||
const isAi = comment.authorType === CommentAuthorType.AiAgent;
|
||||
return (
|
||||
<Box
|
||||
key={comment.id}
|
||||
sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: isAi ? 'primary.main' : 'grey.500'
|
||||
}}
|
||||
>
|
||||
{isAi ? (
|
||||
<SmartToyIcon fontSize="small" />
|
||||
) : (
|
||||
<PersonIcon fontSize="small" />
|
||||
)}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
|
||||
>
|
||||
<Typography variant="subtitle2">
|
||||
{isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{comment.body}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !body.trim()}
|
||||
sx={{ alignSelf: 'flex-end' }}
|
||||
>
|
||||
<FormattedMessage id="addComment" defaultMessage="Add" />
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentThread;
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import {
|
||||
TicketPriority,
|
||||
TicketCategory,
|
||||
TicketSubCategory,
|
||||
subCategoryLabels,
|
||||
subCategoriesByCategory,
|
||||
categoryLabels,
|
||||
otherSubCategoryValues
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
|
||||
type Installation = {
|
||||
id: number;
|
||||
name: string;
|
||||
device: number;
|
||||
};
|
||||
|
||||
const productOptions = [
|
||||
{ value: 0, label: 'Salimax' },
|
||||
{ value: 1, label: 'Salidomo' },
|
||||
{ value: 2, label: 'Sodistore Home' },
|
||||
{ value: 3, label: 'Sodistore Max' },
|
||||
{ value: 4, label: 'Sodistore Grid' },
|
||||
{ value: 5, label: 'Sodistore Pro' }
|
||||
];
|
||||
|
||||
const deviceOptionsByProduct: Record<number, { value: number; label: string }[]> = {
|
||||
1: [
|
||||
{ value: 1, label: 'Cerbo' },
|
||||
{ value: 2, label: 'Venus' }
|
||||
],
|
||||
2: [
|
||||
{ value: 3, label: 'Growatt' },
|
||||
{ value: 4, label: 'inesco 12K - WR Hybrid' }
|
||||
]
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
defaultInstallationId?: number;
|
||||
}
|
||||
|
||||
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
||||
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
||||
const [allInstallations, setAllInstallations] = useState<Installation[]>([]);
|
||||
const [selectedInstallation, setSelectedInstallation] =
|
||||
useState<Installation | null>(null);
|
||||
const [loadingInstallations, setLoadingInstallations] = useState(false);
|
||||
const [priority, setPriority] = useState<number>(TicketPriority.Medium);
|
||||
const [category, setCategory] = useState<number>(TicketCategory.Hardware);
|
||||
const [subCategory, setSubCategory] = useState<number>(
|
||||
TicketSubCategory.Battery
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Custom "Other" fields
|
||||
const [customSubCategory, setCustomSubCategory] = useState('');
|
||||
const [customCategory, setCustomCategory] = useState('');
|
||||
const [customSubSuggestions, setCustomSubSuggestions] = useState<string[]>([]);
|
||||
const [customCatSuggestions, setCustomCatSuggestions] = useState<string[]>([]);
|
||||
|
||||
const hasDeviceOptions =
|
||||
selectedProduct !== '' && selectedProduct in deviceOptionsByProduct;
|
||||
|
||||
const isOtherCategory = category === TicketCategory.Other;
|
||||
const isOtherSubCategory = otherSubCategoryValues.has(subCategory);
|
||||
|
||||
// Fetch custom subcategory suggestions when category changes
|
||||
useEffect(() => {
|
||||
if (!isOtherSubCategory && !isOtherCategory) {
|
||||
setCustomSubSuggestions([]);
|
||||
return;
|
||||
}
|
||||
axiosConfig
|
||||
.get('/GetCustomSubCategories', { params: { category } })
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.data)) setCustomSubSuggestions(res.data);
|
||||
})
|
||||
.catch(() => setCustomSubSuggestions([]));
|
||||
}, [category, isOtherSubCategory, isOtherCategory]);
|
||||
|
||||
// Fetch custom category suggestions when "Other" category is selected
|
||||
useEffect(() => {
|
||||
if (!isOtherCategory) {
|
||||
setCustomCatSuggestions([]);
|
||||
return;
|
||||
}
|
||||
axiosConfig
|
||||
.get('/GetCustomCategories')
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.data)) setCustomCatSuggestions(res.data);
|
||||
})
|
||||
.catch(() => setCustomCatSuggestions([]));
|
||||
}, [isOtherCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProduct === '') {
|
||||
setAllInstallations([]);
|
||||
setSelectedInstallation(null);
|
||||
setSelectedDevice('');
|
||||
return;
|
||||
}
|
||||
setLoadingInstallations(true);
|
||||
setSelectedInstallation(null);
|
||||
setSelectedDevice('');
|
||||
axiosConfig
|
||||
.get('/GetAllInstallationsFromProduct', {
|
||||
params: { product: selectedProduct }
|
||||
})
|
||||
.then((res) => {
|
||||
const data = res.data;
|
||||
if (Array.isArray(data)) {
|
||||
const mapped = data.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
device: item.device
|
||||
}));
|
||||
setAllInstallations(mapped);
|
||||
if (defaultInstallationId != null) {
|
||||
const match = mapped.find((inst: Installation) => inst.id === defaultInstallationId);
|
||||
if (match) {
|
||||
setSelectedInstallation(match);
|
||||
setSelectedDevice(match.device ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => setAllInstallations([]))
|
||||
.finally(() => setLoadingInstallations(false));
|
||||
}, [selectedProduct]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultInstallationId == null || !open) return;
|
||||
axiosConfig
|
||||
.get('/GetInstallationById', { params: { id: defaultInstallationId } })
|
||||
.then((res) => {
|
||||
const inst = res.data;
|
||||
if (inst) {
|
||||
setSelectedProduct(inst.product);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [defaultInstallationId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultInstallationId == null) setSelectedInstallation(null);
|
||||
}, [selectedDevice]);
|
||||
|
||||
// Reset subcategory when category changes
|
||||
useEffect(() => {
|
||||
if (isOtherCategory) {
|
||||
setSubCategory(0); // no subcategory for Other category
|
||||
} else {
|
||||
const subs = subCategoriesByCategory[category];
|
||||
setSubCategory(subs?.[0] ?? 0);
|
||||
}
|
||||
setCustomSubCategory('');
|
||||
setCustomCategory('');
|
||||
}, [category]);
|
||||
|
||||
const filteredInstallations = useMemo(() => {
|
||||
if (!hasDeviceOptions || selectedDevice === '') return allInstallations;
|
||||
return allInstallations.filter((inst) => inst.device === selectedDevice);
|
||||
}, [allInstallations, selectedDevice, hasDeviceOptions]);
|
||||
|
||||
const installationReady =
|
||||
selectedProduct !== '' && (!hasDeviceOptions || selectedDevice !== '');
|
||||
|
||||
const resetForm = () => {
|
||||
setSubject('');
|
||||
setSelectedProduct('');
|
||||
setSelectedDevice('');
|
||||
setAllInstallations([]);
|
||||
setSelectedInstallation(null);
|
||||
setPriority(TicketPriority.Medium);
|
||||
setCategory(TicketCategory.Hardware);
|
||||
setSubCategory(TicketSubCategory.Battery);
|
||||
setDescription('');
|
||||
setCustomSubCategory('');
|
||||
setCustomCategory('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!subject.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
axiosConfig
|
||||
.post('/CreateTicket', {
|
||||
subject,
|
||||
description,
|
||||
installationId: selectedInstallation?.id ?? null,
|
||||
priority,
|
||||
category,
|
||||
subCategory: isOtherCategory ? 0 : subCategory,
|
||||
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
|
||||
customCategory: isOtherCategory ? customCategory || null : null
|
||||
})
|
||||
.then(() => {
|
||||
resetForm();
|
||||
onCreated();
|
||||
onClose();
|
||||
})
|
||||
.catch(() => setError('Failed to create ticket.'))
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
const availableSubCategories = subCategoriesByCategory[category] ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<FormattedMessage id="createTicket" defaultMessage="Create Ticket" />
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}
|
||||
>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<TextField
|
||||
label={<FormattedMessage id="subject" defaultMessage="Subject" />}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="product" defaultMessage="Product" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedProduct}
|
||||
label="Product"
|
||||
onChange={(e) =>
|
||||
setSelectedProduct(
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{productOptions.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{hasDeviceOptions && (
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="device" defaultMessage="Device" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedDevice}
|
||||
label="Device"
|
||||
onChange={(e) =>
|
||||
setSelectedDevice(
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{deviceOptionsByProduct[selectedProduct as number].map((d) => (
|
||||
<MenuItem key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Autocomplete<Installation, false, false, false>
|
||||
options={filteredInstallations}
|
||||
getOptionLabel={(opt) => opt.name}
|
||||
value={selectedInstallation}
|
||||
onChange={(_e, val) => setSelectedInstallation(val)}
|
||||
disabled={!installationReady}
|
||||
loading={loadingInstallations}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="installation"
|
||||
defaultMessage="Installation"
|
||||
/>
|
||||
}
|
||||
margin="dense"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{loadingInstallations ? (
|
||||
<CircularProgress size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={priority}
|
||||
label="Priority"
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
>
|
||||
<MenuItem value={TicketPriority.Critical}>Critical</MenuItem>
|
||||
<MenuItem value={TicketPriority.High}>High</MenuItem>
|
||||
<MenuItem value={TicketPriority.Medium}>Medium</MenuItem>
|
||||
<MenuItem value={TicketPriority.Low}>Low</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="category" defaultMessage="Category" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={category}
|
||||
label="Category"
|
||||
onChange={(e) => setCategory(Number(e.target.value))}
|
||||
>
|
||||
{Object.entries(categoryLabels).map(([val, label]) => (
|
||||
<MenuItem key={val} value={Number(val)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Custom category label when "Other" category is selected */}
|
||||
{isOtherCategory && (
|
||||
<Autocomplete<string, false, false, true>
|
||||
freeSolo
|
||||
options={customCatSuggestions}
|
||||
value={customCategory}
|
||||
onInputChange={(_e, val) => setCustomCategory(val)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="customCategoryLabel"
|
||||
defaultMessage="Category Label"
|
||||
/>
|
||||
}
|
||||
placeholder="Type or select from existing..."
|
||||
margin="dense"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subcategory dropdown — hidden when category is "Other" */}
|
||||
{!isOtherCategory && availableSubCategories.length > 0 && (
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage
|
||||
id="subCategory"
|
||||
defaultMessage="Sub-Category"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={subCategory}
|
||||
label="Sub-Category"
|
||||
onChange={(e) => {
|
||||
setSubCategory(Number(e.target.value));
|
||||
setCustomSubCategory('');
|
||||
}}
|
||||
>
|
||||
{availableSubCategories.map((val) => (
|
||||
<MenuItem key={val} value={val}>
|
||||
{subCategoryLabels[val] ?? 'Unknown'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* Custom subcategory free-text with autocomplete when "Other" sub is selected */}
|
||||
{(isOtherSubCategory || isOtherCategory) && (
|
||||
<Autocomplete<string, false, false, true>
|
||||
freeSolo
|
||||
options={customSubSuggestions}
|
||||
value={customSubCategory}
|
||||
onInputChange={(_e, val) => setCustomSubCategory(val)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="customSubCategoryLabel"
|
||||
defaultMessage="Custom Sub-Category"
|
||||
/>
|
||||
}
|
||||
placeholder="Type or select from existing..."
|
||||
margin="dense"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage id="description" defaultMessage="Description" />
|
||||
}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !subject.trim()}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateTicketModal;
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { Ticket, TicketStatus } from 'src/interfaces/TicketTypes';
|
||||
import StatusChip from './StatusChip';
|
||||
import CreateTicketModal from './CreateTicketModal';
|
||||
|
||||
interface Props {
|
||||
installationId: number;
|
||||
}
|
||||
|
||||
const statusCountKeys: {
|
||||
status: number;
|
||||
id: string;
|
||||
defaultMessage: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
||||
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
||||
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
||||
];
|
||||
|
||||
function InstallationTicketsTab({ installationId }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const intl = useIntl();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const fetchTickets = () => {
|
||||
axiosConfig
|
||||
.get('/GetTicketsForInstallation', { params: { installationId } })
|
||||
.then((res) => {
|
||||
setTickets(res.data);
|
||||
setError('');
|
||||
})
|
||||
.catch(() => setError('Failed to load tickets.'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [installationId]);
|
||||
|
||||
const statusCounts = statusCountKeys.map((s) => ({
|
||||
...s,
|
||||
count: tickets.filter((t) => t.status === s.status).length
|
||||
}));
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">
|
||||
<FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<FormattedMessage id="createTicket" defaultMessage="Create Ticket" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={1.5} mb={2} flexWrap="wrap">
|
||||
{statusCounts.map((s) => (
|
||||
<Chip
|
||||
key={s.status}
|
||||
label={`${intl.formatMessage({ id: s.id, defaultMessage: s.defaultMessage })}: ${s.count}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: s.count > 0 ? s.color : '#e0e0e0',
|
||||
color: s.count > 0 ? '#fff' : '#757575',
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tickets.length === 0 && !error ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<FormattedMessage id="noTickets" defaultMessage="No tickets found." />
|
||||
</Typography>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="subject" defaultMessage="Subject" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="createdAt" defaultMessage="Created" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tickets
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime()
|
||||
)
|
||||
.map((ticket) => (
|
||||
<TableRow
|
||||
key={ticket.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/tickets/${ticket.id}`)}
|
||||
>
|
||||
<TableCell>{ticket.id}</TableCell>
|
||||
<TableCell>{ticket.subject}</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip status={ticket.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{intl.formatMessage({
|
||||
id: `priority${['Critical', 'High', 'Medium', 'Low'][ticket.priority]}`,
|
||||
defaultMessage: ['Critical', 'High', 'Medium', 'Low'][
|
||||
ticket.priority
|
||||
]
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<CreateTicketModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
fetchTickets();
|
||||
}}
|
||||
defaultInstallationId={installationId}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallationTicketsTab;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { Chip } from '@mui/material';
|
||||
import { TicketStatus } from 'src/interfaces/TicketTypes';
|
||||
|
||||
const statusLabels: Record<number, string> = {
|
||||
[TicketStatus.Open]: 'Open',
|
||||
[TicketStatus.InProgress]: 'In Progress',
|
||||
[TicketStatus.Escalated]: 'Escalated',
|
||||
[TicketStatus.Resolved]: 'Resolved',
|
||||
[TicketStatus.Closed]: 'Closed'
|
||||
};
|
||||
|
||||
const statusColors: Record<
|
||||
number,
|
||||
'error' | 'warning' | 'info' | 'success' | 'default'
|
||||
> = {
|
||||
[TicketStatus.Open]: 'error',
|
||||
[TicketStatus.InProgress]: 'warning',
|
||||
[TicketStatus.Escalated]: 'error',
|
||||
[TicketStatus.Resolved]: 'success',
|
||||
[TicketStatus.Closed]: 'default'
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
status: number;
|
||||
size?: 'small' | 'medium';
|
||||
}
|
||||
|
||||
function StatusChip({ status, size = 'small' }: StatusChipProps) {
|
||||
return (
|
||||
<Chip
|
||||
label={statusLabels[status] ?? 'Unknown'}
|
||||
color={statusColors[status] ?? 'default'}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusChip;
|
||||
|
|
@ -0,0 +1,860 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import routes from 'src/Resources/routes.json';
|
||||
import {
|
||||
TicketDetail as TicketDetailType,
|
||||
TicketStatus,
|
||||
TicketPriority,
|
||||
TicketCategory,
|
||||
AdminUser,
|
||||
subCategoryLabels,
|
||||
subCategoryKeys,
|
||||
subCategoriesByCategory,
|
||||
categoryLabels,
|
||||
categoryKeys,
|
||||
otherSubCategoryValues,
|
||||
getCategoryDisplayLabel,
|
||||
getSubCategoryDisplayLabel
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
import Footer from 'src/components/Footer';
|
||||
import StatusChip from './StatusChip';
|
||||
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||
import CommentThread from './CommentThread';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
|
||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
|
||||
[TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
|
||||
[TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
|
||||
[TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
|
||||
};
|
||||
|
||||
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
||||
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
||||
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
|
||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
|
||||
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
|
||||
];
|
||||
|
||||
function TicketDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const intl = useIntl();
|
||||
const [detail, setDetail] = useState<TicketDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
|
||||
const [updatingAssignee, setUpdatingAssignee] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [rootCause, setRootCause] = useState('');
|
||||
const [solution, setSolution] = useState('');
|
||||
const [savingResolution, setSavingResolution] = useState(false);
|
||||
const [resolutionError, setResolutionError] = useState('');
|
||||
const [resolutionSaved, setResolutionSaved] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [editingDescription, setEditingDescription] = useState(false);
|
||||
const [savingDescription, setSavingDescription] = useState(false);
|
||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||
|
||||
// Custom "Other" editing state
|
||||
const [editCustomSub, setEditCustomSub] = useState('');
|
||||
const [editCustomCat, setEditCustomCat] = useState('');
|
||||
const [customSubSuggestions, setCustomSubSuggestions] = useState<string[]>([]);
|
||||
const [customCatSuggestions, setCustomCatSuggestions] = useState<string[]>([]);
|
||||
|
||||
const fetchDetail = useCallback(() => {
|
||||
if (!id) return;
|
||||
axiosConfig
|
||||
.get('/GetTicketDetail', { params: { id: Number(id) } })
|
||||
.then((res) => {
|
||||
setDetail(res.data);
|
||||
setRootCause(res.data.ticket.rootCause ?? '');
|
||||
setSolution(res.data.ticket.solution ?? '');
|
||||
setDescription(res.data.ticket.description ?? '');
|
||||
setEditCustomSub(res.data.ticket.customSubCategory ?? '');
|
||||
setEditCustomCat(res.data.ticket.customCategory ?? '');
|
||||
setError('');
|
||||
})
|
||||
.catch(() => setError('Failed to load ticket details.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetail();
|
||||
axiosConfig
|
||||
.get('/GetAdminUsers')
|
||||
.then((res) => setAdminUsers(res.data))
|
||||
.catch(() => {});
|
||||
}, [fetchDetail]);
|
||||
|
||||
// Fetch custom subcategory suggestions when ticket's category changes
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
axiosConfig
|
||||
.get('/GetCustomSubCategories', { params: { category: detail.ticket.category } })
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.data)) setCustomSubSuggestions(res.data);
|
||||
})
|
||||
.catch(() => setCustomSubSuggestions([]));
|
||||
}, [detail?.ticket.category]);
|
||||
|
||||
// Fetch custom category suggestions when "Other" category
|
||||
useEffect(() => {
|
||||
if (!detail || detail.ticket.category !== TicketCategory.Other) {
|
||||
setCustomCatSuggestions([]);
|
||||
return;
|
||||
}
|
||||
axiosConfig
|
||||
.get('/GetCustomCategories')
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.data)) setCustomCatSuggestions(res.data);
|
||||
})
|
||||
.catch(() => setCustomCatSuggestions([]));
|
||||
}, [detail?.ticket.category]);
|
||||
|
||||
const handleStatusChange = (newStatus: number) => {
|
||||
if (!detail) return;
|
||||
if (
|
||||
newStatus === TicketStatus.Resolved &&
|
||||
(!rootCause.trim() || !solution.trim())
|
||||
) {
|
||||
setResolutionError(
|
||||
'Root Cause and Solution are required to resolve a ticket.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setResolutionError('');
|
||||
setUpdatingStatus(true);
|
||||
axiosConfig
|
||||
.put('/UpdateTicket', {
|
||||
...detail.ticket,
|
||||
status: newStatus,
|
||||
rootCause,
|
||||
solution
|
||||
})
|
||||
.then(() => fetchDetail())
|
||||
.catch(() => setError('Failed to update status.'))
|
||||
.finally(() => setUpdatingStatus(false));
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (assigneeId: number | '') => {
|
||||
if (!detail) return;
|
||||
setUpdatingAssignee(true);
|
||||
axiosConfig
|
||||
.put('/UpdateTicket', {
|
||||
...detail.ticket,
|
||||
assigneeId: assigneeId === '' ? null : assigneeId
|
||||
})
|
||||
.then(() => fetchDetail())
|
||||
.catch(() => setError('Failed to update assignee.'))
|
||||
.finally(() => setUpdatingAssignee(false));
|
||||
};
|
||||
|
||||
const handleTicketFieldChange = (fields: Partial<typeof detail.ticket>) => {
|
||||
if (!detail) return;
|
||||
axiosConfig
|
||||
.put('/UpdateTicket', { ...detail.ticket, ...fields })
|
||||
.then(() => fetchDetail())
|
||||
.catch(() => setError('Failed to update ticket.'));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!id) return;
|
||||
setDeleting(true);
|
||||
axiosConfig
|
||||
.delete('/DeleteTicket', { params: { id: Number(id) } })
|
||||
.then(() => navigate('/tickets'))
|
||||
.catch(() => {
|
||||
setError('Failed to delete ticket.');
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDescription = () => {
|
||||
if (!detail) return;
|
||||
setSavingDescription(true);
|
||||
setDescriptionSaved(false);
|
||||
axiosConfig
|
||||
.put('/UpdateTicket', { ...detail.ticket, description })
|
||||
.then(() => {
|
||||
fetchDetail();
|
||||
setDescriptionSaved(true);
|
||||
setEditingDescription(false);
|
||||
})
|
||||
.catch(() => setError('Failed to save description.'))
|
||||
.finally(() => setSavingDescription(false));
|
||||
};
|
||||
|
||||
const handleSaveResolution = () => {
|
||||
if (!detail) return;
|
||||
setSavingResolution(true);
|
||||
setResolutionError('');
|
||||
setResolutionSaved(false);
|
||||
axiosConfig
|
||||
.put('/UpdateTicket', { ...detail.ticket, rootCause, solution })
|
||||
.then(() => {
|
||||
fetchDetail();
|
||||
setResolutionSaved(true);
|
||||
})
|
||||
.catch(() => setResolutionError('Failed to save resolution.'))
|
||||
.finally(() => setSavingResolution(false));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error || 'Ticket not found.'}</Alert>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/tickets')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<FormattedMessage id="backToTickets" defaultMessage="Back to Tickets" />
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const { ticket, comments, diagnosis, timeline } = detail;
|
||||
|
||||
const isOtherCategory = ticket.category === TicketCategory.Other;
|
||||
const isOtherSubCategory = otherSubCategoryValues.has(ticket.subCategory);
|
||||
const catDisplay = getCategoryDisplayLabel(ticket.category, ticket.customCategory);
|
||||
const subDisplay = getSubCategoryDisplayLabel(ticket.subCategory, ticket.customSubCategory);
|
||||
|
||||
return (
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
<Container maxWidth="xl" sx={{ mt: '20px' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/tickets')}
|
||||
>
|
||||
<FormattedMessage id="backToTickets" defaultMessage="Back to Tickets" />
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<FormattedMessage id="deleteTicket" defaultMessage="Delete" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography variant="h3" gutterBottom>
|
||||
#{ticket.id} — {ticket.subject}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
<StatusChip status={ticket.status} size="medium" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}
|
||||
{catDisplay}
|
||||
{subDisplay !== 'Other' && subDisplay !== 'Unknown' && subDisplay !== 'General'
|
||||
? ` · ${subDisplay}`
|
||||
: ticket.customSubCategory
|
||||
? ` · ${ticket.customSubCategory}`
|
||||
: ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Left column: Description, AI Diagnosis, Comments */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="description"
|
||||
defaultMessage="Description"
|
||||
/>
|
||||
}
|
||||
action={
|
||||
!editingDescription && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => setEditingDescription(true)}
|
||||
>
|
||||
<FormattedMessage id="edit" defaultMessage="Edit" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
{editingDescription ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
setDescriptionSaved(false);
|
||||
}}
|
||||
/>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||
{descriptionSaved && (
|
||||
<Typography variant="body2" color="success.main">
|
||||
<FormattedMessage id="descriptionSaved" defaultMessage="Description saved." />
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingDescription(false);
|
||||
setDescription(ticket.description ?? '');
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={savingDescription}
|
||||
>
|
||||
<FormattedMessage id="save" defaultMessage="Save" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{ticket.description || (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="noDescription"
|
||||
defaultMessage="No description provided."
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<AiDiagnosisPanel
|
||||
diagnosis={diagnosis}
|
||||
onRefresh={fetchDetail}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="resolution"
|
||||
defaultMessage="Resolution"
|
||||
/>
|
||||
}
|
||||
subheader={
|
||||
detail.ticket.preFilledFromAi ? (
|
||||
<Typography variant="caption" color="info.main">
|
||||
<FormattedMessage
|
||||
id="preFilledFromAi"
|
||||
defaultMessage="Pre-filled from AI diagnosis"
|
||||
/>
|
||||
</Typography>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
{ticket.status === TicketStatus.Resolved ||
|
||||
ticket.status === TicketStatus.Closed ? (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="rootCauseLabel"
|
||||
defaultMessage="Root Cause"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{ticket.rootCause || '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="solutionLabel"
|
||||
defaultMessage="Solution"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{ticket.solution || '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="rootCauseLabel"
|
||||
defaultMessage="Root Cause"
|
||||
/>
|
||||
}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
value={rootCause}
|
||||
onChange={(e) => setRootCause(e.target.value)}
|
||||
error={
|
||||
!!resolutionError && !rootCause.trim()
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="solutionLabel"
|
||||
defaultMessage="Solution"
|
||||
/>
|
||||
}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
value={solution}
|
||||
onChange={(e) => setSolution(e.target.value)}
|
||||
error={
|
||||
!!resolutionError && !solution.trim()
|
||||
}
|
||||
/>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||
{resolutionSaved && (
|
||||
<Typography variant="body2" color="success.main">
|
||||
<FormattedMessage
|
||||
id="resolutionSaved"
|
||||
defaultMessage="Resolution saved successfully."
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
{resolutionError && (
|
||||
<Typography variant="body2" color="error.main">
|
||||
{resolutionError}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleSaveResolution}
|
||||
disabled={
|
||||
savingResolution ||
|
||||
(!rootCause.trim() && !solution.trim())
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="saveResolution"
|
||||
defaultMessage="Save Resolution"
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CommentThread
|
||||
ticketId={ticket.id}
|
||||
comments={comments}
|
||||
onCommentAdded={fetchDetail}
|
||||
adminUsers={adminUsers}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Right column: Status, Assignee, Details, Timeline */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="updateStatus"
|
||||
defaultMessage="Update Status"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={ticket.status}
|
||||
label="Status"
|
||||
onChange={(e) =>
|
||||
handleStatusChange(Number(e.target.value))
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
{statusKeys.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{intl.formatMessage({ id: opt.id, defaultMessage: opt.defaultMessage })}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="assignee" defaultMessage="Assignee" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={ticket.assigneeId ?? ''}
|
||||
label="Assignee"
|
||||
onChange={(e) =>
|
||||
handleAssigneeChange(
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
disabled={updatingAssignee}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>
|
||||
<FormattedMessage
|
||||
id="unassigned"
|
||||
defaultMessage="Unassigned"
|
||||
/>
|
||||
</em>
|
||||
</MenuItem>
|
||||
{adminUsers.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={ticket.priority}
|
||||
label="Priority"
|
||||
onChange={(e) =>
|
||||
handleTicketFieldChange({ priority: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{Object.entries(priorityKeys).map(([value, msg]) => (
|
||||
<MenuItem key={value} value={Number(value)}>
|
||||
{intl.formatMessage(msg)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="category" defaultMessage="Category" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={ticket.category}
|
||||
label="Category"
|
||||
onChange={(e) => {
|
||||
const newCat = Number(e.target.value);
|
||||
const isNewOther = newCat === TicketCategory.Other;
|
||||
const firstSub = subCategoriesByCategory[newCat]?.[0] ?? 0;
|
||||
handleTicketFieldChange({
|
||||
category: newCat,
|
||||
subCategory: isNewOther ? 0 : firstSub,
|
||||
customSubCategory: null,
|
||||
customCategory: null
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Object.entries(categoryLabels).map(([value, label]) => (
|
||||
<MenuItem key={value} value={Number(value)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Custom category label when "Other" category */}
|
||||
{isOtherCategory && (
|
||||
<Autocomplete<string, false, false, true>
|
||||
freeSolo
|
||||
options={customCatSuggestions}
|
||||
value={editCustomCat}
|
||||
onInputChange={(_e, val) => setEditCustomCat(val)}
|
||||
onBlur={() => {
|
||||
if (editCustomCat !== (ticket.customCategory ?? '')) {
|
||||
handleTicketFieldChange({ customCategory: editCustomCat || null });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="customCategoryLabel"
|
||||
defaultMessage="Category Label"
|
||||
/>
|
||||
}
|
||||
placeholder="Type category name..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subcategory dropdown — hidden when category is "Other" */}
|
||||
{!isOtherCategory && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="subCategory" defaultMessage="Sub-Category" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={ticket.subCategory}
|
||||
label="Sub-Category"
|
||||
onChange={(e) => {
|
||||
const newSub = Number(e.target.value);
|
||||
handleTicketFieldChange({
|
||||
subCategory: newSub,
|
||||
customSubCategory: otherSubCategoryValues.has(newSub) ? ticket.customSubCategory : null
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(subCategoriesByCategory[ticket.category] ?? []).map((sc) => (
|
||||
<MenuItem key={sc} value={sc}>
|
||||
{subCategoryKeys[sc] ? intl.formatMessage(subCategoryKeys[sc]) : subCategoryLabels[sc] ?? 'Unknown'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* Custom subcategory when "Other" sub is selected */}
|
||||
{(isOtherSubCategory || isOtherCategory) && (
|
||||
<Autocomplete<string, false, false, true>
|
||||
freeSolo
|
||||
options={customSubSuggestions}
|
||||
value={editCustomSub}
|
||||
onInputChange={(_e, val) => setEditCustomSub(val)}
|
||||
onBlur={() => {
|
||||
if (editCustomSub !== (ticket.customSubCategory ?? '')) {
|
||||
handleTicketFieldChange({ customSubCategory: editCustomSub || null });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="customSubCategoryLabel"
|
||||
defaultMessage="Custom Sub-Category"
|
||||
/>
|
||||
}
|
||||
placeholder="Type or select from existing..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage id="details" defaultMessage="Details" />
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="installation"
|
||||
defaultMessage="Installation"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={detail.installationProduct != null && detail.ticket.installationId != null ? {
|
||||
color: 'primary.main',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
} : {}}
|
||||
onClick={() => {
|
||||
if (detail.installationProduct == null || detail.ticket.installationId == null) return;
|
||||
const productRoutes: Record<number, string> = {
|
||||
0: routes.installations,
|
||||
1: routes.salidomo_installations,
|
||||
2: routes.sodiohome_installations,
|
||||
3: routes.sodistore_installations,
|
||||
4: routes.sodistoregrid_installations,
|
||||
5: routes.sodistorepro_installations
|
||||
};
|
||||
const prefix = productRoutes[detail.installationProduct] ?? routes.installations;
|
||||
navigate(
|
||||
prefix + routes.list + routes.installation + detail.ticket.installationId + '/' + routes.live
|
||||
);
|
||||
}}
|
||||
>
|
||||
{detail.installationName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="createdBy"
|
||||
defaultMessage="Created By"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{detail.creatorName}
|
||||
</Typography>
|
||||
</Box>
|
||||
{detail.assigneeName && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="assignee"
|
||||
defaultMessage="Assignee"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{detail.assigneeName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="createdAt"
|
||||
defaultMessage="Created"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{new Date(ticket.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="updatedAt"
|
||||
defaultMessage="Updated"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{new Date(ticket.updatedAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
{ticket.resolvedAt && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="resolvedAt"
|
||||
defaultMessage="Resolved"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{new Date(ticket.resolvedAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TimelinePanel events={timeline} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="confirmDeleteTicket"
|
||||
defaultMessage="Delete Ticket?"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<FormattedMessage
|
||||
id="confirmDeleteTicketMessage"
|
||||
defaultMessage="This will permanently delete this ticket, its comments, AI diagnosis, and timeline. This action cannot be undone."
|
||||
/>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)} disabled={deleting}>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<FormattedMessage id="deleteTicket" defaultMessage="Delete" />
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketDetailPage;
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import {
|
||||
TicketSummary,
|
||||
TicketStatus,
|
||||
TicketPriority,
|
||||
categoryKeys,
|
||||
subCategoryKeys,
|
||||
getCategoryDisplayLabel,
|
||||
getSubCategoryDisplayLabel
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
import Footer from 'src/components/Footer';
|
||||
import CreateTicketModal from './CreateTicketModal';
|
||||
import StatusChip from './StatusChip';
|
||||
|
||||
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
||||
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
|
||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
|
||||
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
|
||||
};
|
||||
|
||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
|
||||
[TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
|
||||
[TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
|
||||
[TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
|
||||
};
|
||||
|
||||
function TicketList() {
|
||||
const navigate = useNavigate();
|
||||
const intl = useIntl();
|
||||
const [tickets, setTickets] = useState<TicketSummary[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<number | ''>('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchTickets = () => {
|
||||
axiosConfig
|
||||
.get('/GetTicketSummaries')
|
||||
.then((res) => setTickets(res.data))
|
||||
.catch(() => setError('Failed to load tickets'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const filtered = tickets
|
||||
.filter((t) => {
|
||||
const matchesSearch =
|
||||
search === '' ||
|
||||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.installationName.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus = statusFilter === '' || t.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return (
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }}>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
mb={2}
|
||||
>
|
||||
<Typography variant="h3">
|
||||
<FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="createTicket"
|
||||
defaultMessage="Create Ticket"
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={2} mb={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={<FormattedMessage id="search" defaultMessage="Search" />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<FormattedMessage
|
||||
id="allStatuses"
|
||||
defaultMessage="All Statuses"
|
||||
/>
|
||||
</MenuItem>
|
||||
{Object.entries(statusKeys).map(([val, msg]) => (
|
||||
<MenuItem key={val} value={Number(val)}>
|
||||
{intl.formatMessage(msg)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && !error ? (
|
||||
<Alert severity="info">
|
||||
<FormattedMessage
|
||||
id="noTickets"
|
||||
defaultMessage="No tickets found."
|
||||
/>
|
||||
</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="subject"
|
||||
defaultMessage="Subject"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="status"
|
||||
defaultMessage="Status"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="priority"
|
||||
defaultMessage="Priority"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="category"
|
||||
defaultMessage="Category"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="installation"
|
||||
defaultMessage="Installation"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="createdAt"
|
||||
defaultMessage="Created"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((ticket) => {
|
||||
const catLabel = getCategoryDisplayLabel(ticket.category, ticket.customCategory);
|
||||
const catKey = categoryKeys[ticket.category];
|
||||
const catDisplay = catKey
|
||||
? intl.formatMessage(catKey)
|
||||
: catLabel;
|
||||
const subLabel = getSubCategoryDisplayLabel(ticket.subCategory, ticket.customSubCategory);
|
||||
const subKey = subCategoryKeys[ticket.subCategory];
|
||||
const subDisplay = subKey
|
||||
? intl.formatMessage(subKey)
|
||||
: subLabel;
|
||||
|
||||
return (
|
||||
<TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/tickets/${ticket.id}`)}>
|
||||
<TableCell>{ticket.id}</TableCell>
|
||||
<TableCell>{ticket.subject}</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip status={ticket.status} />
|
||||
</TableCell>
|
||||
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
|
||||
<TableCell>
|
||||
{ticket.customCategory
|
||||
? ticket.customCategory
|
||||
: catDisplay}
|
||||
{subLabel !== 'Other' && subLabel !== 'Unknown' && subLabel !== 'General'
|
||||
? ` — ${ticket.customSubCategory || subDisplay}`
|
||||
: ticket.customSubCategory
|
||||
? ` — ${ticket.customSubCategory}`
|
||||
: ''}
|
||||
</TableCell>
|
||||
<TableCell>{ticket.installationName}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<CreateTicketModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={fetchTickets}
|
||||
/>
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketList;
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Divider,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import {
|
||||
TicketTimelineEvent,
|
||||
TimelineEventType
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
|
||||
const eventTypeKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TimelineEventType.Created]: { id: 'timelineCreated', defaultMessage: 'Created' },
|
||||
[TimelineEventType.StatusChanged]: { id: 'timelineStatusChanged', defaultMessage: 'Status Changed' },
|
||||
[TimelineEventType.Assigned]: { id: 'timelineAssigned', defaultMessage: 'Assigned' },
|
||||
[TimelineEventType.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' },
|
||||
[TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' },
|
||||
[TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' },
|
||||
[TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' }
|
||||
};
|
||||
|
||||
const eventTypeColors: Record<number, string> = {
|
||||
[TimelineEventType.Created]: '#1976d2',
|
||||
[TimelineEventType.StatusChanged]: '#ed6c02',
|
||||
[TimelineEventType.Assigned]: '#9c27b0',
|
||||
[TimelineEventType.CommentAdded]: '#2e7d32',
|
||||
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
|
||||
[TimelineEventType.Escalated]: '#d32f2f',
|
||||
[TimelineEventType.ResolutionAdded]: '#4caf50'
|
||||
};
|
||||
|
||||
interface TimelinePanelProps {
|
||||
events: TicketTimelineEvent[];
|
||||
}
|
||||
|
||||
function TimelinePanel({ events }: TimelinePanelProps) {
|
||||
const intl = useIntl();
|
||||
const sorted = [...events].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage id="timeline" defaultMessage="Timeline" />
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
{sorted.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="noTimelineEvents"
|
||||
defaultMessage="No events yet."
|
||||
/>
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{sorted.map((event) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
eventTypeColors[event.eventType] ?? '#757575',
|
||||
mt: 0.8,
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{eventTypeKeys[event.eventType]
|
||||
? intl.formatMessage(eventTypeKeys[event.eventType])
|
||||
: 'Event'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{event.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{new Date(event.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelinePanel;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue