Compare commits
No commits in common. "795e77d304c79be46206334dee704c16a8f784b6" and "c189a077fb9df1df98f9d68d72a4d5a3c7685cfc" have entirely different histories.
795e77d304
...
c189a077fb
|
|
@ -1899,22 +1899,15 @@ public class Controller : ControllerBase
|
||||||
public async Task<ActionResult<IEnumerable<Object>>> EditInstallationConfig([FromBody] Configuration config, Int64 installationId,int product,Token authToken)
|
public async Task<ActionResult<IEnumerable<Object>>> EditInstallationConfig([FromBody] Configuration config, Int64 installationId,int product,Token authToken)
|
||||||
{
|
{
|
||||||
var session = Db.GetSession(authToken);
|
var session = Db.GetSession(authToken);
|
||||||
|
|
||||||
// Dynamic Pricing in Spot Price mode: forward the provider chosen on the Information tab
|
string configString = product switch
|
||||||
// so the device knows which operator's API to query for spot prices.
|
|
||||||
if (config.DynamicPricingMode == "SpotPrice")
|
|
||||||
{
|
{
|
||||||
var installation = Db.GetInstallationById(installationId);
|
0 => config.GetConfigurationSalimax(), // Salimax
|
||||||
config.NetworkProvider = installation?.NetworkProvider;
|
3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax
|
||||||
}
|
2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome
|
||||||
|
4 => config.GetConfigurationSodistoreGrid(), // SodistoreGrid
|
||||||
// Serialize what was actually sent — drops null/unset fields so the audit
|
_ => config.GetConfigurationString() // fallback
|
||||||
// entry is product-shaped automatically (no per-product formatter to maintain).
|
};
|
||||||
var configString = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions
|
|
||||||
{
|
|
||||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
WriteIndented = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
Console.WriteLine("CONFIG IS " + configString);
|
Console.WriteLine("CONFIG IS " + configString);
|
||||||
|
|
||||||
|
|
@ -2591,38 +2584,19 @@ public class Controller : ControllerBase
|
||||||
.ToList()
|
.ToList()
|
||||||
.ToDictionary(i => i.Id);
|
.ToDictionary(i => i.Id);
|
||||||
|
|
||||||
var assigneeIds = tickets
|
|
||||||
.Where(t => t.AssigneeId.HasValue)
|
|
||||||
.Select(t => t.AssigneeId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var assigneesById = assigneeIds.Count == 0
|
|
||||||
? new Dictionary<Int64, User>()
|
|
||||||
: Db.Users
|
|
||||||
.Where(u => assigneeIds.Contains(u.Id))
|
|
||||||
.ToList()
|
|
||||||
.ToDictionary(u => u.Id);
|
|
||||||
|
|
||||||
var summaries = tickets.Select(t =>
|
var summaries = tickets.Select(t =>
|
||||||
{
|
{
|
||||||
Installation? installation = null;
|
Installation? installation = null;
|
||||||
if (t.InstallationId.HasValue)
|
if (t.InstallationId.HasValue)
|
||||||
installationsById.TryGetValue(t.InstallationId.Value, out installation);
|
installationsById.TryGetValue(t.InstallationId.Value, out installation);
|
||||||
|
|
||||||
User? assignee = null;
|
|
||||||
if (t.AssigneeId.HasValue)
|
|
||||||
assigneesById.TryGetValue(t.AssigneeId.Value, out assignee);
|
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
|
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
|
||||||
t.InstallationId, t.CreatedAt, t.UpdatedAt,
|
t.InstallationId, t.CreatedAt, t.UpdatedAt,
|
||||||
t.CustomSubCategory, t.CustomCategory,
|
t.CustomSubCategory, t.CustomCategory,
|
||||||
t.AssigneeId,
|
|
||||||
installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation"),
|
installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation"),
|
||||||
distributionPartner = installation?.DistributionPartner ?? "",
|
distributionPartner = installation?.DistributionPartner ?? ""
|
||||||
assigneeName = assignee?.Name
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,7 @@ public static class ChecklistStepDefinitions
|
||||||
"""
|
"""
|
||||||
[
|
[
|
||||||
{"text":"checklistStep10Sub1","checked":false},
|
{"text":"checklistStep10Sub1","checked":false},
|
||||||
{"text":"checklistStep10Sub2","checked":false},
|
{"text":"checklistStep10Sub2","checked":false}
|
||||||
{"text":"checklistStep10Sub3","checked":false}
|
|
||||||
]
|
]
|
||||||
"""),
|
"""),
|
||||||
new(11, "Software verified on site", NoSubtasks),
|
new(11, "Software verified on site", NoSubtasks),
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,51 @@ public class Configuration
|
||||||
public CalibrationChargeType? CalibrationDischargeState { get; set; }
|
public CalibrationChargeType? CalibrationDischargeState { get; set; }
|
||||||
public DateTime? CalibrationDischargeDate { get; set; }
|
public DateTime? CalibrationDischargeDate { get; set; }
|
||||||
|
|
||||||
// V1 (legacy) flat fields — still used by the original SodistoreHomeConfiguration page
|
|
||||||
// for installations not opted in to V2. WhenWritingNull keeps them out of V2 payloads.
|
|
||||||
public double? MaximumDischargingCurrent { get; set; }
|
public double? MaximumDischargingCurrent { get; set; }
|
||||||
public double? MaximumChargingCurrent { get; set; }
|
public double? MaximumChargingCurrent { get; set; }
|
||||||
public int? InverterNumber { get; set; }
|
public double? OperatingPriority { get; set; }
|
||||||
public double? BatteriesCount { get; set; }
|
public double? BatteriesCount { get; set; }
|
||||||
public List<int>? BatteriesCountPerInverter { get; set; }
|
|
||||||
public double? ClusterNumber { get; set; }
|
public double? ClusterNumber { get; set; }
|
||||||
public double? PvNumber { get; set; }
|
public double? PvNumber { get; set; }
|
||||||
// V2 — per-inverter Clusters + PvCount, keyed by "Inverter1".."InverterN".
|
|
||||||
// Wire format mirrors the on-disk shape — device merges these into its existing Devices.InverterN entries.
|
|
||||||
// Per-cluster MaxChargingCurrent / MaxDischargingCurrent live inside Devices[InverterN].Clusters[ClusterN].
|
|
||||||
public Dictionary<string, DeviceConfigPartial>? Devices { get; set; }
|
|
||||||
public double? OperatingPriority { get; set; }
|
|
||||||
public bool ControlPermission { get; set; }
|
public bool ControlPermission { get; set; }
|
||||||
public double? TimeChargeandDischargePower { get; set; }
|
public double? TimeChargeandDischargePower { get; set; }
|
||||||
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
||||||
public DateTime? StopTimeChargeandDischargeDayandTime { get; set; }
|
public DateTime? StopTimeChargeandDischargeDayandTime { get; set; }
|
||||||
|
|
||||||
|
public String GetConfigurationString()
|
||||||
|
{
|
||||||
|
return $"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}, " +
|
||||||
|
$"CalibrationDischargeState: {CalibrationDischargeState}, CalibrationDischargeDate: {CalibrationDischargeDate}, " +
|
||||||
|
$"MaximumDischargingCurrent: {MaximumDischargingCurrent}, MaximumChargingCurrent: {MaximumChargingCurrent}, OperatingPriority: {OperatingPriority}, " +
|
||||||
|
$"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+
|
||||||
|
$"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}";
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetConfigurationSalimax()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetConfigurationSodistoreMax()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}";
|
||||||
|
}
|
||||||
|
|
||||||
// Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later.
|
public string GetConfigurationSodistoreHome()
|
||||||
public string? DynamicPricingMode { get; set; }
|
{
|
||||||
public string? NetworkProvider { get; set; }
|
return $"MinimumSoC: {MinimumSoC}, MaximumDischargingCurrent: {MaximumDischargingCurrent}, MaximumChargingCurrent: {MaximumChargingCurrent}, OperatingPriority: {OperatingPriority}, " +
|
||||||
public string? CurrentPrice { get; set; }
|
$"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+
|
||||||
public string? PriceToSell { get; set; }
|
$"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}";
|
||||||
public string? PriceToBuy { get; set; }
|
}
|
||||||
// TOU windows stored as "HH:mm" strings
|
|
||||||
public string? TimeToSellFrom { get; set; }
|
|
||||||
public string? TimeToSellTo { get; set; }
|
|
||||||
public string? TimeToBuyFrom { get; set; }
|
|
||||||
public string? TimeToBuyTo { get; set; }
|
|
||||||
|
|
||||||
|
// TODO: SodistoreGrid — update configuration fields when defined
|
||||||
|
public string GetConfigurationSodistoreGrid()
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CalibrationChargeType
|
public enum CalibrationChargeType
|
||||||
|
|
@ -47,17 +61,4 @@ public enum CalibrationChargeType
|
||||||
RepetitivelyEvery,
|
RepetitivelyEvery,
|
||||||
AdditionallyOnce,
|
AdditionallyOnce,
|
||||||
ChargePermanently
|
ChargePermanently
|
||||||
}
|
|
||||||
|
|
||||||
public class DeviceConfigPartial
|
|
||||||
{
|
|
||||||
public Dictionary<string, ClusterConfig>? Clusters { get; set; }
|
|
||||||
public int? PvCount { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ClusterConfig
|
|
||||||
{
|
|
||||||
public int BatteryCount { get; set; }
|
|
||||||
public double MaxChargingCurrent { get; set; }
|
|
||||||
public double MaxDischargingCurrent { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
@ -448,16 +448,12 @@ public static class ExoCmd
|
||||||
for (int j = 0; j < maxRetransmissions; j++)
|
for (int j = 0; j < maxRetransmissions; j++)
|
||||||
{
|
{
|
||||||
//string message = "This is a message from RabbitMQ server, you can subscribe to the RabbitMQ queue";
|
//string message = "This is a message from RabbitMQ server, you can subscribe to the RabbitMQ queue";
|
||||||
// Drop null fields so the device only sees what's actually set for this product.
|
byte[] data = Encoding.UTF8.GetBytes(JsonSerializer.Serialize<Configuration>(config));
|
||||||
var jsonOptions = new System.Text.Json.JsonSerializerOptions
|
|
||||||
{
|
|
||||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
||||||
};
|
|
||||||
var payload = JsonSerializer.Serialize<Configuration>(config, jsonOptions);
|
|
||||||
byte[] data = Encoding.UTF8.GetBytes(payload);
|
|
||||||
udpClient.Send(data, data.Length, installation.VpnIp, port);
|
udpClient.Send(data, data.Length, installation.VpnIp, port);
|
||||||
|
|
||||||
Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}: {payload}");
|
Console.WriteLine(config.GetConfigurationString());
|
||||||
|
|
||||||
|
Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}: {config}");
|
||||||
//Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}"+" GridSetPoint is "+config.GridSetPoint +" and MinimumSoC is "+config.MinimumSoC);
|
//Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}"+" GridSetPoint is "+config.GridSetPoint +" and MinimumSoC is "+config.MinimumSoC);
|
||||||
|
|
||||||
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse(installation.VpnIp), port);
|
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse(installation.VpnIp), port);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using InnovEnergy.App.Backend.Database;
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.Relations;
|
using InnovEnergy.App.Backend.Relations;
|
||||||
|
using InnovEnergy.Lib.Mailer;
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend.DataTypes.Methods;
|
namespace InnovEnergy.App.Backend.DataTypes.Methods;
|
||||||
|
|
@ -172,4 +173,38 @@ public static class InstallationMethods
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const String SupportEmail = "support@inesco.energy";
|
||||||
|
private const String SupportName = "inesco energy Support Team";
|
||||||
|
|
||||||
|
public static Task SendAlarmNotificationToSupport(this Installation installation, Int32 prevStatus)
|
||||||
|
{
|
||||||
|
var productName = ProductName(installation.Product);
|
||||||
|
var fromStatus = StatusName(prevStatus);
|
||||||
|
|
||||||
|
var subject = $"[inesco energy] Alarm: {installation.Name}";
|
||||||
|
var body =
|
||||||
|
$"Installation \"{installation.Name}\" (ID {installation.Id}, {productName})\n" +
|
||||||
|
$"status changed from {fromStatus} to Alarm.\n\n" +
|
||||||
|
"Please check the Log tab on the Monitor to see detailed errors and warnings.\n";
|
||||||
|
|
||||||
|
return Mailer.Send(SupportName, SupportEmail, subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String StatusName(Int32 status) => status switch
|
||||||
|
{
|
||||||
|
-1 => "Offline",
|
||||||
|
0 => "Green",
|
||||||
|
1 => "Warning",
|
||||||
|
2 => "Alarm",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static String ProductName(Int32 product) => product switch
|
||||||
|
{
|
||||||
|
2 => "Sodistore Home",
|
||||||
|
3 => "Sodistore Max",
|
||||||
|
4 => "Sodistore Grid",
|
||||||
|
5 => "Sodistore Pro",
|
||||||
|
_ => $"Product {product}"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -149,45 +149,18 @@ public static partial class Db
|
||||||
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 8 AND (Subtasks IS NULL OR Subtasks = '')",
|
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 8 AND (Subtasks IS NULL OR Subtasks = '')",
|
||||||
"[{\"text\":\"checklistStep8Sub1\",\"checked\":false}]");
|
"[{\"text\":\"checklistStep8Sub1\",\"checked\":false}]");
|
||||||
|
|
||||||
// One-time backfill: step 10 originally had no subtasks; add the upload subtasks
|
// One-time backfill: step 10 originally had no subtasks; add the two upload subtasks
|
||||||
// to existing rows.
|
// (installation protocol + time & material report) to existing rows.
|
||||||
Connection.Execute(
|
Connection.Execute(
|
||||||
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 10 AND (Subtasks IS NULL OR Subtasks = '')",
|
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 10 AND (Subtasks IS NULL OR Subtasks = '')",
|
||||||
"[{\"text\":\"checklistStep10Sub1\",\"checked\":false},{\"text\":\"checklistStep10Sub2\",\"checked\":false},{\"text\":\"checklistStep10Sub3\",\"checked\":false}]");
|
"[{\"text\":\"checklistStep10Sub1\",\"checked\":false},{\"text\":\"checklistStep10Sub2\",\"checked\":false}]");
|
||||||
|
|
||||||
// One-time backfill: append step 10 sub3 (contact Atef for external EMS) to rows
|
|
||||||
// that already have subtasks but lack sub3. Done in C# to handle the multi-line
|
|
||||||
// raw-string JSON form preserving each row's existing checked state.
|
|
||||||
BackfillStep10Sub3();
|
|
||||||
|
|
||||||
//UpdateKeys();
|
//UpdateKeys();
|
||||||
CleanupSessions().SupressAwaitWarning();
|
CleanupSessions().SupressAwaitWarning();
|
||||||
DeleteSnapshots().SupressAwaitWarning();
|
DeleteSnapshots().SupressAwaitWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BackfillStep10Sub3()
|
|
||||||
{
|
|
||||||
var rows = Connection.Query<ChecklistItem>(
|
|
||||||
"SELECT * FROM ChecklistItem WHERE StepNumber = 10 AND Subtasks IS NOT NULL AND Subtasks != ''");
|
|
||||||
foreach (var row in rows)
|
|
||||||
{
|
|
||||||
if (row.Subtasks is null) continue;
|
|
||||||
if (row.Subtasks.Contains("checklistStep10Sub3")) continue;
|
|
||||||
|
|
||||||
// Insert sub3 before the closing bracket. Works regardless of indentation/whitespace
|
|
||||||
// since we only look for the last `]`.
|
|
||||||
var lastBracket = row.Subtasks.LastIndexOf(']');
|
|
||||||
if (lastBracket < 0) continue;
|
|
||||||
|
|
||||||
var head = row.Subtasks.Substring(0, lastBracket).TrimEnd();
|
|
||||||
// head ends with `}` of the last existing subtask.
|
|
||||||
var newJson = head + ",{\"text\":\"checklistStep10Sub3\",\"checked\":false}]";
|
|
||||||
Connection.Execute(
|
|
||||||
"UPDATE ChecklistItem SET Subtasks = ? WHERE Id = ?",
|
|
||||||
newJson, row.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SQLiteConnection InitConnection()
|
private static SQLiteConnection InitConnection()
|
||||||
{
|
{
|
||||||
var latestDb = new DirectoryInfo("DbBackups")
|
var latestDb = new DirectoryInfo("DbBackups")
|
||||||
|
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using InnovEnergy.App.Backend.Database;
|
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
|
||||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
|
||||||
using InnovEnergy.App.Backend.Websockets;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend.Services;
|
|
||||||
|
|
||||||
public static class AutoTicketService
|
|
||||||
{
|
|
||||||
private const String NicoLappName = "Nico Lapp";
|
|
||||||
private const String AtefSarrajName = "Atef Sarraj";
|
|
||||||
|
|
||||||
private const String TagAlarm = "auto:alarm";
|
|
||||||
private const String TagOffline = "auto:offline";
|
|
||||||
|
|
||||||
private const Int32 AlarmStatus = (Int32)StatusType.Alarm;
|
|
||||||
private const Int32 OfflineStatus = (Int32)StatusType.Offline;
|
|
||||||
|
|
||||||
public static async Task MaybeCreateForAlarmAsync(
|
|
||||||
Installation installation,
|
|
||||||
Int32 prevStatus,
|
|
||||||
IReadOnlyList<AlarmOrWarning> alarms)
|
|
||||||
{
|
|
||||||
if (!IsSodistore(installation.Product)) return;
|
|
||||||
if (prevStatus == AlarmStatus) return;
|
|
||||||
// Edit/testing mode: alarms during commissioning shouldn't open tickets.
|
|
||||||
if (installation.TestingMode) return;
|
|
||||||
|
|
||||||
var assignee = FindAdminByName(NicoLappName);
|
|
||||||
if (assignee is null)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[AutoTicket] assignee \"{NicoLappName}\" not found, skipping alarm ticket for installation {installation.Id}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alarms.Count == 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[AutoTicket] alarm transition for installation {installation.Id} carried no alarm payload, skipping (likely transient)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HasOpenAutoTicket(installation.Id, TagAlarm)) return;
|
|
||||||
|
|
||||||
var lang = String.IsNullOrWhiteSpace(assignee.Language) ? "en" : assignee.Language;
|
|
||||||
var firstName = SplitCamelCase(alarms[0].Description);
|
|
||||||
var suffix = alarms.Count > 1 ? $" (+{alarms.Count - 1} more)" : "";
|
|
||||||
var subject = $"[Auto] {installation.Name} entered error state [{firstName}]{suffix}";
|
|
||||||
|
|
||||||
var description = await BuildAlarmDescriptionAsync(installation, prevStatus, alarms, lang);
|
|
||||||
|
|
||||||
CreateTicket(installation, assignee, subject, description, TagAlarm);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Task MaybeCreateForOfflineAsync(
|
|
||||||
Installation installation,
|
|
||||||
Int32 prevStatus,
|
|
||||||
DateTime lastSeen)
|
|
||||||
{
|
|
||||||
if (!IsSodistore(installation.Product)) return Task.CompletedTask;
|
|
||||||
if (prevStatus == OfflineStatus) return Task.CompletedTask;
|
|
||||||
// Edit/testing mode: offline transitions during commissioning shouldn't open tickets.
|
|
||||||
if (installation.TestingMode) return Task.CompletedTask;
|
|
||||||
|
|
||||||
var assignee = FindAdminByName(AtefSarrajName);
|
|
||||||
if (assignee is null)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[AutoTicket] assignee \"{AtefSarrajName}\" not found, skipping offline ticket for installation {installation.Id}");
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HasOpenAutoTicket(installation.Id, TagOffline)) return Task.CompletedTask;
|
|
||||||
|
|
||||||
var subject = $"[Auto] {installation.Name} went offline";
|
|
||||||
var description = BuildOfflineDescription(installation, prevStatus, lastSeen);
|
|
||||||
|
|
||||||
CreateTicket(installation, assignee, subject, description, TagOffline);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CreateTicket(
|
|
||||||
Installation installation,
|
|
||||||
User assignee,
|
|
||||||
String subject,
|
|
||||||
String description,
|
|
||||||
String tag)
|
|
||||||
{
|
|
||||||
var ticket = new Ticket
|
|
||||||
{
|
|
||||||
Subject = subject,
|
|
||||||
Description = description,
|
|
||||||
Status = (Int32)TicketStatus.Open,
|
|
||||||
Priority = (Int32)TicketPriority.Critical,
|
|
||||||
Category = (Int32)TicketCategory.Other,
|
|
||||||
SubCategory = 0,
|
|
||||||
Source = (Int32)TicketSource.AutoAlert,
|
|
||||||
InstallationId = installation.Id,
|
|
||||||
AssigneeId = assignee.Id,
|
|
||||||
CreatedByUserId = assignee.Id,
|
|
||||||
Tags = tag,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!Db.Create(ticket))
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[AutoTicket] Db.Create failed for installation {installation.Id} ({tag})");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var timelineOk = Db.Create(new TicketTimelineEvent
|
|
||||||
{
|
|
||||||
TicketId = ticket.Id,
|
|
||||||
EventType = (Int32)TimelineEventType.Created,
|
|
||||||
Description = $"Ticket auto-created by status monitor ({tag}).",
|
|
||||||
ActorType = (Int32)TimelineActorType.System,
|
|
||||||
ActorId = null,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
if (!timelineOk)
|
|
||||||
Console.WriteLine($"[AutoTicket] timeline event insert failed for ticket {ticket.Id} (informational only — ticket itself is fine)");
|
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
|
||||||
catch (Exception ex) { Console.WriteLine($"[AutoTicket] assignment email failed for ticket {ticket.Id}: {ex.Message}"); }
|
|
||||||
});
|
|
||||||
|
|
||||||
var lang = String.IsNullOrWhiteSpace(assignee.Language) ? "en" : assignee.Language;
|
|
||||||
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Boolean HasOpenAutoTicket(Int64 installationId, String tag)
|
|
||||||
{
|
|
||||||
return Db.GetTicketsForInstallation(installationId).Any(t =>
|
|
||||||
t.Source == (Int32)TicketSource.AutoAlert
|
|
||||||
&& (t.Status == (Int32)TicketStatus.Open || t.Status == (Int32)TicketStatus.InProgress)
|
|
||||||
&& t.Tags == tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static User? FindAdminByName(String name) =>
|
|
||||||
Db.Users.FirstOrDefault(u => u.UserType == 2 && u.Name == name);
|
|
||||||
|
|
||||||
private static Boolean IsSodistore(Int32 product) => product is 2 or 3 or 4 or 5;
|
|
||||||
|
|
||||||
private static String SplitCamelCase(String name) =>
|
|
||||||
Regex.Replace(name ?? "", @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim();
|
|
||||||
|
|
||||||
private static async Task<String> BuildAlarmDescriptionAsync(
|
|
||||||
Installation installation,
|
|
||||||
Int32 prevStatus,
|
|
||||||
IReadOnlyList<AlarmOrWarning> alarms,
|
|
||||||
String lang)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("Auto-created by status monitor.");
|
|
||||||
sb.AppendLine($"Installation: {installation.Name} (id {installation.Id})");
|
|
||||||
sb.AppendLine($"Location: {installation.Location}");
|
|
||||||
sb.AppendLine($"Transition: {StatusLabel(prevStatus)} → Alarm");
|
|
||||||
sb.AppendLine($"Detected at: {DateTime.UtcNow:O} UTC");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"—— Alarms ({alarms.Count}) ——");
|
|
||||||
|
|
||||||
foreach (var alarm in alarms)
|
|
||||||
{
|
|
||||||
var displayName = SplitCamelCase(alarm.Description);
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"[{displayName}] (raw: {alarm.Description}) — {alarm.Date} {alarm.Time} from {alarm.CreatedBy}");
|
|
||||||
|
|
||||||
DiagnosticResponse? diag = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
diag = await DiagnosticService.DiagnoseAsync(installation.Id, alarm.Description, lang);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[AutoTicket] DiagnoseAsync failed for {alarm.Description}: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diag is null)
|
|
||||||
{
|
|
||||||
sb.AppendLine(" (no diagnosis available)");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine($" Explanation: {diag.Explanation}");
|
|
||||||
|
|
||||||
if (diag.Causes is { Count: > 0 })
|
|
||||||
{
|
|
||||||
sb.AppendLine(" Likely causes:");
|
|
||||||
foreach (var cause in diag.Causes) sb.AppendLine($" - {cause}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diag.NextSteps is { Count: > 0 })
|
|
||||||
{
|
|
||||||
sb.AppendLine(" Suggested next steps:");
|
|
||||||
var i = 1;
|
|
||||||
foreach (var step in diag.NextSteps) sb.AppendLine($" {i++}. {step}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String BuildOfflineDescription(Installation installation, Int32 prevStatus, DateTime lastSeen)
|
|
||||||
{
|
|
||||||
// lastSeen is captured from WebsocketManager which uses DateTime.Now (server local time);
|
|
||||||
// normalise to UTC so the rendered timestamps + "min ago" delta are consistent and unambiguous.
|
|
||||||
var lastSeenUtc = lastSeen.Kind == DateTimeKind.Utc ? lastSeen : lastSeen.ToUniversalTime();
|
|
||||||
var minutesAgo = (DateTime.UtcNow - lastSeenUtc).TotalMinutes;
|
|
||||||
var (productLabel, timeoutMin) = OfflineProfile(installation.Product);
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("Auto-created by status monitor.");
|
|
||||||
sb.AppendLine($"Installation: {installation.Name} (id {installation.Id})");
|
|
||||||
sb.AppendLine($"Location: {installation.Location}");
|
|
||||||
sb.AppendLine($"Transition: {StatusLabel(prevStatus)} → Offline");
|
|
||||||
sb.AppendLine($"Detected at: {DateTime.UtcNow:O} UTC");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"Last heartbeat received: {lastSeenUtc:O} UTC ({minutesAgo:F1} min ago)");
|
|
||||||
sb.AppendLine($"Expected heartbeat interval for {productLabel}: {timeoutMin} min");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("This ticket was auto-created because no message was received from the");
|
|
||||||
sb.AppendLine("installation within the expected interval. Please verify network/VPN");
|
|
||||||
sb.AppendLine("connectivity, then check the device side.");
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String StatusLabel(Int32 status) => status switch
|
|
||||||
{
|
|
||||||
(Int32)StatusType.Offline => "Offline",
|
|
||||||
(Int32)StatusType.Green => "Green",
|
|
||||||
(Int32)StatusType.Warning => "Warning",
|
|
||||||
(Int32)StatusType.Alarm => "Alarm",
|
|
||||||
-2 => "Unknown",
|
|
||||||
_ => $"Status({status})"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static (String label, Int32 timeoutMinutes) OfflineProfile(Int32 product) => product switch
|
|
||||||
{
|
|
||||||
(Int32)ProductType.SodioHome => ("Sodistore Home", 4),
|
|
||||||
(Int32)ProductType.SodiStoreMax => ("Sodistore Max", 2),
|
|
||||||
(Int32)ProductType.SodistoreGrid => ("Sodistore Grid", 2),
|
|
||||||
(Int32)ProductType.SodistorePro => ("Sodistore Pro", 4),
|
|
||||||
_ => ($"Product {product}", 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ using System.Text.Json;
|
||||||
using InnovEnergy.App.Backend.Database;
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||||
using InnovEnergy.App.Backend.Services;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using RabbitMQ.Client.Events;
|
using RabbitMQ.Client.Events;
|
||||||
|
|
@ -195,12 +194,10 @@ public static class RabbitMqManager
|
||||||
&& receivedStatusMessage.Status == AlarmStatus)
|
&& receivedStatusMessage.Status == AlarmStatus)
|
||||||
{
|
{
|
||||||
var prev = prevStatus;
|
var prev = prevStatus;
|
||||||
var alarmsSnapshot = (IReadOnlyList<AlarmOrWarning>)
|
|
||||||
(receivedStatusMessage.Alarms?.ToList() ?? new List<AlarmOrWarning>());
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try { await AutoTicketService.MaybeCreateForAlarmAsync(installation, prev, alarmsSnapshot); }
|
try { await installation.SendAlarmNotificationToSupport(prev); }
|
||||||
catch (Exception ex) { Console.WriteLine($"[AutoTicket] alarm failed for {installationId}: {ex.Message}"); }
|
catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using InnovEnergy.App.Backend.Database;
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
using InnovEnergy.App.Backend.Services;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend.Websockets;
|
namespace InnovEnergy.App.Backend.Websockets;
|
||||||
|
|
@ -19,7 +18,6 @@ public static class WebsocketManager
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var idsToInform = new List<Int64>();
|
var idsToInform = new List<Int64>();
|
||||||
var offlineTransitions = new List<(Int64 Id, Int32 PrevStatus, DateTime LastSeen)>();
|
|
||||||
|
|
||||||
lock (InstallationConnections)
|
lock (InstallationConnections)
|
||||||
{
|
{
|
||||||
|
|
@ -40,18 +38,12 @@ public static class WebsocketManager
|
||||||
Console.WriteLine("Installation ID is " + installationConnection.Key);
|
Console.WriteLine("Installation ID is " + installationConnection.Key);
|
||||||
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
||||||
|
|
||||||
var prevStatus = installationConnection.Value.Status;
|
|
||||||
var lastSeen = installationConnection.Value.Timestamp;
|
|
||||||
|
|
||||||
installationConnection.Value.Status = (int)StatusType.Offline;
|
installationConnection.Value.Status = (int)StatusType.Offline;
|
||||||
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
||||||
if (installationConnection.Value.Connections.Count > 0)
|
if (installationConnection.Value.Connections.Count > 0)
|
||||||
{
|
{
|
||||||
idsToInform.Add(installationConnection.Key);
|
idsToInform.Add(installationConnection.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevStatus != (int)StatusType.Offline)
|
|
||||||
offlineTransitions.Add((installationConnection.Key, prevStatus, lastSeen));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,18 +52,6 @@ public static class WebsocketManager
|
||||||
foreach (var id in idsToInform)
|
foreach (var id in idsToInform)
|
||||||
await InformWebsocketsForInstallation(id);
|
await InformWebsocketsForInstallation(id);
|
||||||
|
|
||||||
// Auto-create tickets for fresh offline transitions (outside the lock — needs DB reads + Task.Run)
|
|
||||||
foreach (var (id, prevStatus, lastSeen) in offlineTransitions)
|
|
||||||
{
|
|
||||||
var installation = Db.GetInstallationById(id);
|
|
||||||
if (installation is null) continue;
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try { await AutoTicketService.MaybeCreateForOfflineAsync(installation, prevStatus, lastSeen); }
|
|
||||||
catch (Exception ex) { Console.WriteLine($"[AutoTicket] offline failed for {id}: {ex.Message}"); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1));
|
await Task.Delay(TimeSpan.FromMinutes(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,5 @@ public class Configuration
|
||||||
public Single TimeChargeandDischargePower { get; set; }
|
public Single TimeChargeandDischargePower { get; set; }
|
||||||
public Boolean ControlPermission { get; set; }
|
public Boolean ControlPermission { get; set; }
|
||||||
|
|
||||||
// Dynamic Pricing (under GridPriority) — strings for demo; engine parses when needed.
|
|
||||||
public String? DynamicPricingMode { get; set; }
|
|
||||||
public String? NetworkProvider { get; set; }
|
|
||||||
public String? CurrentPrice { get; set; }
|
|
||||||
public String? PriceToSell { get; set; }
|
|
||||||
public String? PriceToBuy { get; set; }
|
|
||||||
public String? TimeToSellFrom { get; set; }
|
|
||||||
public String? TimeToSellTo { get; set; }
|
|
||||||
public String? TimeToBuyFrom { get; set; }
|
|
||||||
public String? TimeToBuyTo { get; set; }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -639,15 +639,6 @@ internal static class Program
|
||||||
status.Config.PvNumber = config.PvNumber;
|
status.Config.PvNumber = config.PvNumber;
|
||||||
status.Config.ControlPermission = config.ControlPermission;
|
status.Config.ControlPermission = config.ControlPermission;
|
||||||
|
|
||||||
status.Config.DynamicPricingMode = config.DynamicPricingMode;
|
|
||||||
status.Config.NetworkProvider = config.NetworkProvider;
|
|
||||||
status.Config.CurrentPrice = config.CurrentPrice;
|
|
||||||
status.Config.PriceToSell = config.PriceToSell;
|
|
||||||
status.Config.PriceToBuy = config.PriceToBuy;
|
|
||||||
status.Config.TimeToSellFrom = config.TimeToSellFrom;
|
|
||||||
status.Config.TimeToSellTo = config.TimeToSellTo;
|
|
||||||
status.Config.TimeToBuyFrom = config.TimeToBuyFrom;
|
|
||||||
status.Config.TimeToBuyTo = config.TimeToBuyTo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Boolean> SaveModbusTcpFile(StatusRecord status)
|
private static async Task<Boolean> SaveModbusTcpFile(StatusRecord status)
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@ public class Config
|
||||||
//public required Decimal CheapPrice { get; set; }
|
//public required Decimal CheapPrice { get; set; }
|
||||||
//public required Decimal HighPrice { get; set; }
|
//public required Decimal HighPrice { get; set; }
|
||||||
public required Double MinSoc { get; set; }
|
public required Double MinSoc { get; set; }
|
||||||
public required Double GridSetPoint { get; set; }
|
public required Double GridSetPoint { get; set; }
|
||||||
public required Double MaximumDischargingCurrent { get; set; }
|
public required Double MaximumDischargingCurrent { get; set; }
|
||||||
public required Double MaximumChargingCurrent { get; set; }
|
public required Double MaximumChargingCurrent { get; set; }
|
||||||
public required OperatingPriority OperatingPriority { get; set; }
|
public required OperatingPriority OperatingPriority { get; set; }
|
||||||
public required Int16 BatteriesCount { get; set; }
|
public required Int16 BatteriesCount { get; set; }
|
||||||
public required Int16 ClusterNumber { get; set; }
|
public required Int16 ClusterNumber { get; set; }
|
||||||
public required Int16 PvNumber { get; set; }
|
public required Int16 PvNumber { get; set; }
|
||||||
|
|
@ -34,18 +34,7 @@ public class Config
|
||||||
public required DateTime StopTimeChargeandDischargeDayandTime { get; set; }
|
public required DateTime StopTimeChargeandDischargeDayandTime { get; set; }
|
||||||
|
|
||||||
public required Single TimeChargeandDischargePower { get; set; }
|
public required Single TimeChargeandDischargePower { get; set; }
|
||||||
public required Boolean ControlPermission { get; set; }
|
public required Boolean ControlPermission { get; set; }
|
||||||
|
|
||||||
// Dynamic Pricing (under GridPriority) — strings for demo; engine parses when needed.
|
|
||||||
public String? DynamicPricingMode { get; set; }
|
|
||||||
public String? NetworkProvider { get; set; }
|
|
||||||
public String? CurrentPrice { get; set; }
|
|
||||||
public String? PriceToSell { get; set; }
|
|
||||||
public String? PriceToBuy { get; set; }
|
|
||||||
public String? TimeToSellFrom { get; set; }
|
|
||||||
public String? TimeToSellTo { get; set; }
|
|
||||||
public String? TimeToBuyFrom { get; set; }
|
|
||||||
public String? TimeToBuyTo { get; set; }
|
|
||||||
|
|
||||||
|
|
||||||
public required S3Config? S3 { get; set; }
|
public required S3Config? S3 { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -329,8 +329,6 @@ export interface JSONRecordData {
|
||||||
MaximumDischargingCurrent: number;
|
MaximumDischargingCurrent: number;
|
||||||
OperatingPriority: string;
|
OperatingPriority: string;
|
||||||
BatteriesCount: number;
|
BatteriesCount: number;
|
||||||
InverterNumber?: number;
|
|
||||||
BatteriesCountPerInverter?: number[];
|
|
||||||
ClusterNumber: number;
|
ClusterNumber: number;
|
||||||
PvNumber: number;
|
PvNumber: number;
|
||||||
ControlPermission:boolean;
|
ControlPermission:boolean;
|
||||||
|
|
@ -685,17 +683,6 @@ export interface I_BoxDataValue {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClusterConfig = {
|
|
||||||
BatteryCount: number;
|
|
||||||
MaxChargingCurrent: number;
|
|
||||||
MaxDischargingCurrent: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InverterConfig = {
|
|
||||||
Clusters: { [clusterKey: string]: ClusterConfig };
|
|
||||||
PvCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfigurationValues = {
|
export type ConfigurationValues = {
|
||||||
minimumSoC: string | number;
|
minimumSoC: string | number;
|
||||||
gridSetPoint: number;
|
gridSetPoint: number;
|
||||||
|
|
@ -707,33 +694,16 @@ export type ConfigurationValues = {
|
||||||
//For sodistoreHome
|
//For sodistoreHome
|
||||||
maximumDischargingCurrent: number;
|
maximumDischargingCurrent: number;
|
||||||
maximumChargingCurrent: number;
|
maximumChargingCurrent: number;
|
||||||
// Per-inverter Clusters + PvCount, keyed by "Inverter1".."InverterN".
|
|
||||||
// Wire format mirrors the on-disk Devices.InverterN shape — device merges by key.
|
|
||||||
devices?: { [inverterKey: string]: InverterConfig };
|
|
||||||
operatingPriority: number;
|
operatingPriority: number;
|
||||||
batteriesCount: number;
|
batteriesCount: number;
|
||||||
inverterNumber: number;
|
|
||||||
batteriesCountPerInverter: number[];
|
|
||||||
clusterNumber: number;
|
clusterNumber: number;
|
||||||
PvNumber: number;
|
PvNumber: number;
|
||||||
pvCountPerInverter: number[];
|
|
||||||
controlPermission:boolean;
|
controlPermission:boolean;
|
||||||
|
|
||||||
// For sodistoreHome-Sinexcel: TimeChargeDischarge mode
|
// For sodistoreHome-Sinexcel: TimeChargeDischarge mode
|
||||||
timeChargeandDischargePower?: number;
|
timeChargeandDischargePower?: number;
|
||||||
startTimeChargeandDischargeDayandTime?: Date | null;
|
startTimeChargeandDischargeDayandTime?: Date | null;
|
||||||
stopTimeChargeandDischargeDayandTime?: Date | null;
|
stopTimeChargeandDischargeDayandTime?: Date | null;
|
||||||
|
|
||||||
// For sodistoreHome-Sinexcel: Dynamic Pricing (under GridPriority)
|
|
||||||
dynamicPricingMode?: string;
|
|
||||||
currentPrice?: string;
|
|
||||||
priceToSell?: string;
|
|
||||||
priceToBuy?: string;
|
|
||||||
// TOU time windows stored as "HH:mm" strings
|
|
||||||
timeToSellFrom?: string;
|
|
||||||
timeToSellTo?: string;
|
|
||||||
timeToBuyFrom?: string;
|
|
||||||
timeToBuyTo?: string;
|
|
||||||
};
|
};
|
||||||
//
|
//
|
||||||
// export interface Pv {
|
// export interface Pv {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { TimeSpan, UnixTime } from '../../../dataCache/time';
|
||||||
import { fetchDataJson } from '../Installations/fetchData';
|
import { fetchDataJson } from '../Installations/fetchData';
|
||||||
import { FetchResult } from '../../../dataCache/dataCache';
|
import { FetchResult } from '../../../dataCache/dataCache';
|
||||||
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
|
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
|
||||||
import SodistoreHomeConfigurationV2 from './SodistoreHomeConfigurationV2';
|
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
||||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||||
import Overview from '../Overview/overview';
|
import Overview from '../Overview/overview';
|
||||||
import WeeklyReport from './WeeklyReport';
|
import WeeklyReport from './WeeklyReport';
|
||||||
|
|
@ -599,11 +599,11 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
<Route
|
<Route
|
||||||
path={routes.configuration}
|
path={routes.configuration}
|
||||||
element={
|
element={
|
||||||
<SodistoreHomeConfigurationV2
|
<SodistoreHomeConfiguration
|
||||||
values={values}
|
values={values}
|
||||||
id={props.current_installation.id}
|
id={props.current_installation.id}
|
||||||
installation={props.current_installation}
|
installation={props.current_installation}
|
||||||
/>
|
></SodistoreHomeConfiguration>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
import { ConfigurationValues, InverterConfig, JSONRecordData } from '../Log/graph.util';
|
import { ConfigurationValues, JSONRecordData } from '../Log/graph.util';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -20,7 +15,6 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
|
|
||||||
import React, { useContext, useState, useEffect } from 'react';
|
import React, { useContext, useState, useEffect } from 'react';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
@ -31,15 +25,9 @@ import axiosConfig from '../../../Resources/axiosConfig';
|
||||||
import { UserContext } from '../../../contexts/userContext';
|
import { UserContext } from '../../../contexts/userContext';
|
||||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import {
|
|
||||||
buildSodistoreProPreset,
|
|
||||||
getPresetsForDevice,
|
|
||||||
parseBatterySnTree,
|
|
||||||
PresetConfig
|
|
||||||
} from '../Information/installationSetupUtils';
|
|
||||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
import { DateTimePicker, TimePicker } from '@mui/x-date-pickers';
|
import {DateTimePicker } from '@mui/x-date-pickers';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
@ -50,7 +38,7 @@ interface SodistoreHomeConfigurationProps {
|
||||||
installation: I_Installation;
|
installation: I_Installation;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
if (props.values === null) {
|
if (props.values === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -70,14 +58,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
'PvPriorityCharging': 'GridPriority',
|
'PvPriorityCharging': 'GridPriority',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic Pricing Mode — backend enum values with UI labels
|
|
||||||
const DynamicPricingOptions = ['Disabled', 'SpotPrice', 'Tou'] as const;
|
|
||||||
const dynamicPricingLabelKey: Record<string, string> = {
|
|
||||||
Disabled: 'dynamicPricingOff',
|
|
||||||
SpotPrice: 'dynamicPricingSpotPrice',
|
|
||||||
Tou: 'dynamicPricingTou',
|
|
||||||
};
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState({
|
const [errors, setErrors] = useState({
|
||||||
minimumSoC: false,
|
minimumSoC: false,
|
||||||
gridSetPoint: false
|
gridSetPoint: false
|
||||||
|
|
@ -90,7 +70,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [updated, setUpdated] = useState(false);
|
const [updated, setUpdated] = useState(false);
|
||||||
|
|
@ -109,68 +88,30 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
// Storage key for pending config (optimistic update)
|
// Storage key for pending config (optimistic update)
|
||||||
const pendingConfigKey = `pendingConfig_${props.id}`;
|
const pendingConfigKey = `pendingConfig_${props.id}`;
|
||||||
|
|
||||||
// Hardware topology — derived from Information tab (single source of truth).
|
// Helper to build form values from S3 data
|
||||||
const isSodistorePro = product === 5;
|
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||||
const installationModel = props.installation.installationModel;
|
minimumSoC: props.values.Config.MinSoc,
|
||||||
const presetConfig: PresetConfig | null = isSodistorePro
|
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||||
? (installationModel && parseInt(installationModel, 10) > 0
|
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||||
? buildSodistoreProPreset(parseInt(installationModel, 10))
|
operatingPriority: resolveOperatingPriorityIndex(
|
||||||
: null)
|
props.values.Config.OperatingPriority
|
||||||
: (getPresetsForDevice(device)[installationModel] || null);
|
),
|
||||||
const inverterCount = presetConfig?.length ?? 1;
|
batteriesCount: props.values.Config.BatteriesCount,
|
||||||
|
clusterNumber: props.values.Config.ClusterNumber ?? 1,
|
||||||
// Default battery limit per cluster: 95A per battery for both charge and discharge
|
PvNumber: props.values.Config.PvNumber ?? 0,
|
||||||
// (so 1-battery cluster → 95A, 2-battery cluster → 190A).
|
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
|
||||||
const DEFAULT_CURRENT_PER_BATTERY = 95;
|
startTimeChargeandDischargeDayandTime: (() => {
|
||||||
const defaultClusterCurrent = (batteryCount: number) => batteryCount * DEFAULT_CURRENT_PER_BATTERY;
|
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
|
||||||
|
const parsed = raw ? dayjs(raw) : null;
|
||||||
// (No standalone preset-based builder — overlayTopology() handles the empty case
|
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||||
// and seeds defaults using the actual installed battery count from the Information tab,
|
})(),
|
||||||
// not the preset slot count.)
|
stopTimeChargeandDischargeDayandTime: (() => {
|
||||||
|
const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime;
|
||||||
// Helper to build form values from S3 data.
|
const parsed = raw ? dayjs(raw) : null;
|
||||||
// V2 reads only fields it actually consumes — legacy flat keys (InverterNumber,
|
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||||
// BatteriesCount, MaximumChargingCurrent, etc.) are no longer wired through.
|
})(),
|
||||||
const getS3Values = (): Partial<ConfigurationValues> => {
|
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||||
// Read per-inverter Clusters/PvCount from each Devices.InverterN entry on disk.
|
});
|
||||||
const cfgDevices = (props.values.Config as any).Devices as { [k: string]: any } | undefined;
|
|
||||||
const cfgFromDisk: { [k: string]: InverterConfig } | undefined = cfgDevices
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(cfgDevices)
|
|
||||||
.filter(([k, v]: [string, any]) => k.startsWith('Inverter') && (v?.Clusters || v?.PvCount != null))
|
|
||||||
.map(([k, v]: [string, any]) => [k, { Clusters: v.Clusters ?? {}, PvCount: v.PvCount ?? 0 }])
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const hasDevicesData = cfgFromDisk && Object.keys(cfgFromDisk).length > 0;
|
|
||||||
return {
|
|
||||||
minimumSoC: props.values.Config.MinSoc,
|
|
||||||
// Information tab is source of truth for topology + per-cluster current defaults.
|
|
||||||
devices: overlayTopology(hasDevicesData ? cfgFromDisk : undefined),
|
|
||||||
operatingPriority: resolveOperatingPriorityIndex(
|
|
||||||
props.values.Config.OperatingPriority
|
|
||||||
),
|
|
||||||
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
|
|
||||||
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",
|
|
||||||
dynamicPricingMode: (props.values.Config as any).DynamicPricingMode ?? 'Disabled',
|
|
||||||
currentPrice: (props.values.Config as any).CurrentPrice?.toString() ?? '',
|
|
||||||
priceToSell: (props.values.Config as any).PriceToSell?.toString() ?? '',
|
|
||||||
priceToBuy: (props.values.Config as any).PriceToBuy?.toString() ?? '',
|
|
||||||
timeToSellFrom: (props.values.Config as any).TimeToSellFrom ?? '',
|
|
||||||
timeToSellTo: (props.values.Config as any).TimeToSellTo ?? '',
|
|
||||||
timeToBuyFrom: (props.values.Config as any).TimeToBuyFrom ?? '',
|
|
||||||
timeToBuyTo: (props.values.Config as any).TimeToBuyTo ?? '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore pending config from localStorage, converting date strings back to Date objects.
|
// Restore pending config from localStorage, converting date strings back to Date objects.
|
||||||
// Returns { values, s3ConfigSnapshot } or null if no pending config.
|
// Returns { values, s3ConfigSnapshot } or null if no pending config.
|
||||||
|
|
@ -205,46 +146,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
// Fingerprint S3 Config for change detection (not value comparison)
|
// Fingerprint S3 Config for change detection (not value comparison)
|
||||||
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
|
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
|
||||||
|
|
||||||
// Overlay Information-tab-derived topology onto a `devices` config object.
|
|
||||||
// Battery counts come from the SN tree (filled SNs); PvCount comes from pvStringsPerInverter.
|
|
||||||
// Existing per-cluster current limits are preserved.
|
|
||||||
const overlayTopology = (
|
|
||||||
devices: { [k: string]: InverterConfig } | undefined
|
|
||||||
): { [k: string]: InverterConfig } | undefined => {
|
|
||||||
if (!presetConfig) return devices;
|
|
||||||
const tree = parseBatterySnTree(props.installation.batterySerialNumbers || '', presetConfig);
|
|
||||||
const pvStrings = (props.installation.pvStringsPerInverter || '')
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim());
|
|
||||||
const out: { [k: string]: InverterConfig } = {};
|
|
||||||
presetConfig.forEach((clusters, invIdx) => {
|
|
||||||
const invKey = `Inverter${invIdx + 1}`;
|
|
||||||
const existingInv = devices?.[invKey];
|
|
||||||
const cls: { [k: string]: any } = {};
|
|
||||||
clusters.forEach((slotCount, clIdx) => {
|
|
||||||
const clKey = `Cluster${clIdx + 1}`;
|
|
||||||
const filled = (tree[invIdx]?.[clIdx] ?? []).filter((s) => s !== '').length;
|
|
||||||
const existingCl = existingInv?.Clusters?.[clKey];
|
|
||||||
// Default current = (effective battery count) × 95A.
|
|
||||||
// Partial fill: use the actual installed count (real safety limit).
|
|
||||||
// Empty cluster (no SNs yet): fall back to the cluster's ideal slot count
|
|
||||||
// so a freshly-created installation gets a sensible default.
|
|
||||||
const effectiveCount = filled > 0 ? filled : slotCount;
|
|
||||||
const defaultCurrent = defaultClusterCurrent(effectiveCount);
|
|
||||||
cls[clKey] = {
|
|
||||||
BatteryCount: filled,
|
|
||||||
MaxChargingCurrent: existingCl?.MaxChargingCurrent ?? defaultCurrent,
|
|
||||||
MaxDischargingCurrent: existingCl?.MaxDischargingCurrent ?? defaultCurrent,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
out[invKey] = {
|
|
||||||
Clusters: cls,
|
|
||||||
PvCount: parseInt(pvStrings[invIdx] || '0', 10) || 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize form from localStorage (if pending submit exists) or from S3
|
// Initialize form from localStorage (if pending submit exists) or from S3
|
||||||
// This runs in the useState initializer so the component never renders stale values
|
// This runs in the useState initializer so the component never renders stale values
|
||||||
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
|
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
|
||||||
|
|
@ -270,10 +171,7 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
// When S3 data updates (polled every 60s), reconcile with any pending localStorage.
|
// 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
|
// 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.
|
// submit time, the device has uploaded new data — trust S3 regardless of values.
|
||||||
// Skip reset if the user is actively editing (formDirty).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formDirty) return;
|
|
||||||
|
|
||||||
const s3Values = getS3Values();
|
const s3Values = getS3Values();
|
||||||
const pending = restorePendingConfig();
|
const pending = restorePendingConfig();
|
||||||
|
|
||||||
|
|
@ -296,7 +194,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
}, [props.values]);
|
}, [props.values]);
|
||||||
|
|
||||||
const handleOperatingPriorityChange = (event) => {
|
const handleOperatingPriorityChange = (event) => {
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues({
|
setFormValues({
|
||||||
...formValues,
|
...formValues,
|
||||||
['operatingPriority']: OperatingPriorityOptions.indexOf(
|
['operatingPriority']: OperatingPriorityOptions.indexOf(
|
||||||
|
|
@ -332,13 +229,14 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
if (!validateTimeOnly()) {
|
if (!validateTimeOnly()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Re-overlay Information-tab topology at submit time, so battery counts and PV count
|
|
||||||
// are always the latest from the Information tab (it's the source of truth).
|
|
||||||
const devices = overlayTopology(formValues.devices);
|
|
||||||
const configurationToSend: Partial<ConfigurationValues> = {
|
const configurationToSend: Partial<ConfigurationValues> = {
|
||||||
minimumSoC: formValues.minimumSoC,
|
minimumSoC: formValues.minimumSoC,
|
||||||
devices,
|
maximumDischargingCurrent: formValues.maximumDischargingCurrent,
|
||||||
|
maximumChargingCurrent: formValues.maximumChargingCurrent,
|
||||||
operatingPriority: formValues.operatingPriority,
|
operatingPriority: formValues.operatingPriority,
|
||||||
|
batteriesCount:formValues.batteriesCount,
|
||||||
|
clusterNumber:formValues.clusterNumber,
|
||||||
|
PvNumber:formValues.PvNumber,
|
||||||
timeChargeandDischargePower: formValues.timeChargeandDischargePower,
|
timeChargeandDischargePower: formValues.timeChargeandDischargePower,
|
||||||
startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime
|
startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime
|
||||||
? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
||||||
|
|
@ -346,15 +244,7 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime
|
stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime
|
||||||
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
||||||
: null,
|
: null,
|
||||||
controlPermission:formValues.controlPermission,
|
controlPermission:formValues.controlPermission
|
||||||
dynamicPricingMode: formValues.dynamicPricingMode,
|
|
||||||
currentPrice: formValues.currentPrice,
|
|
||||||
priceToSell: formValues.priceToSell,
|
|
||||||
priceToBuy: formValues.priceToBuy,
|
|
||||||
timeToSellFrom: formValues.timeToSellFrom,
|
|
||||||
timeToSellTo: formValues.timeToSellTo,
|
|
||||||
timeToBuyFrom: formValues.timeToBuyFrom,
|
|
||||||
timeToBuyTo: formValues.timeToBuyTo,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -373,7 +263,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
if (res) {
|
if (res) {
|
||||||
setUpdated(true);
|
setUpdated(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setFormDirty(false);
|
|
||||||
|
|
||||||
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
|
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
|
||||||
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
|
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
|
||||||
|
|
@ -388,7 +277,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormDirty(true);
|
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
if (name === 'minimumSoC') {
|
if (name === 'minimumSoC') {
|
||||||
|
|
@ -421,7 +309,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeChargeDischargeChange = (name: string, value: any) => {
|
const handleTimeChargeDischargeChange = (name: string, value: any) => {
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value
|
[name]: value
|
||||||
|
|
@ -501,18 +388,16 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
<Switch
|
<Switch
|
||||||
name="controlPermission"
|
name="controlPermission"
|
||||||
checked={Boolean(formValues.controlPermission)}
|
checked={Boolean(formValues.controlPermission)}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
controlPermission: e.target.checked,
|
controlPermission: e.target.checked,
|
||||||
}));
|
}))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sx={{ transform: "scale(1.4)", marginLeft: "15px" }}
|
sx={{ transform: "scale(1.4)", marginLeft: "15px" }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sx={{ ml: 0 }}
|
|
||||||
label={
|
label={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="controlPermission"
|
id="controlPermission"
|
||||||
|
|
@ -522,15 +407,56 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(device === 3 || device === 4) && (
|
<div style={{ marginBottom: '5px' }}>
|
||||||
|
<TextField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="batteriesCount "
|
||||||
|
defaultMessage="Batteries Count"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="batteriesCount"
|
||||||
|
value={formValues.batteriesCount}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device === 4 && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<FormattedMessage id="batteryLimits" defaultMessage="Battery Limits" />
|
<TextField
|
||||||
</Typography>
|
label={
|
||||||
<Divider sx={{ mb: 2 }} />
|
<FormattedMessage
|
||||||
|
id="clusterNumber"
|
||||||
|
defaultMessage="Cluster Number"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="clusterNumber"
|
||||||
|
value={formValues.clusterNumber}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '5px' }}>
|
||||||
|
<TextField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="PvNumber"
|
||||||
|
defaultMessage="PV Number"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="PvNumber"
|
||||||
|
value={formValues.PvNumber}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
{/*<TextField*/}
|
{/*<TextField*/}
|
||||||
{/* label={*/}
|
{/* label={*/}
|
||||||
|
|
@ -565,120 +491,35 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(device === 3 || device === 4) ? (
|
<div style={{ marginBottom: '5px' }}>
|
||||||
// Per-cluster, per-inverter charging/discharging current limits — nested config.
|
<TextField
|
||||||
Array.from({ length: inverterCount }, (_, invIdx) => {
|
label={
|
||||||
const invKey = `Inverter${invIdx + 1}`;
|
<FormattedMessage
|
||||||
const clusters = presetConfig?.[invIdx] ?? [0];
|
id="maximumChargingCurrent "
|
||||||
return (
|
defaultMessage="Maximum Charging Current"
|
||||||
<Accordion
|
|
||||||
key={`limits-${invKey}`}
|
|
||||||
defaultExpanded={false}
|
|
||||||
sx={{ ml: 1, mr: 1, mt: 1 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontWeight: 'bold' }}>
|
|
||||||
<FormattedMessage id="inverterN" defaultMessage="Inverter {n}" values={{ n: invIdx + 1 }} />
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
{clusters.map((_slotCount, clIdx) => {
|
|
||||||
const clKey = `Cluster${clIdx + 1}`;
|
|
||||||
const cluster = formValues.devices?.[invKey]?.Clusters?.[clKey];
|
|
||||||
const charge = cluster?.MaxChargingCurrent ?? '';
|
|
||||||
const discharge = cluster?.MaxDischargingCurrent ?? '';
|
|
||||||
const setClusterField = (
|
|
||||||
field: 'MaxChargingCurrent' | 'MaxDischargingCurrent',
|
|
||||||
v: string
|
|
||||||
) => {
|
|
||||||
if (v !== '' && !/^\d*\.?\d*$/.test(v)) return;
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => {
|
|
||||||
const devices = { ...(prev.devices ?? {}) };
|
|
||||||
const inv = devices[invKey]
|
|
||||||
?? { Clusters: {}, PvCount: 0 };
|
|
||||||
const cls = { ...(inv.Clusters ?? {}) };
|
|
||||||
const existing = cls[clKey] ?? {
|
|
||||||
BatteryCount: presetConfig?.[invIdx]?.[clIdx] ?? 0,
|
|
||||||
MaxChargingCurrent: 0,
|
|
||||||
MaxDischargingCurrent: 0,
|
|
||||||
};
|
|
||||||
if (v === '') {
|
|
||||||
// Drop the field while editing so the input renders empty,
|
|
||||||
// not "0". On submit, anything still missing falls back to 0.
|
|
||||||
const next: any = { ...existing };
|
|
||||||
delete next[field];
|
|
||||||
cls[clKey] = next;
|
|
||||||
} else {
|
|
||||||
cls[clKey] = { ...existing, [field]: parseFloat(v) };
|
|
||||||
}
|
|
||||||
devices[invKey] = { ...inv, Clusters: cls };
|
|
||||||
return { ...prev, devices };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div key={`cl-limits-${invKey}-${clKey}`} style={{ marginBottom: '5px' }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 1, ml: 1 }}>
|
|
||||||
<FormattedMessage id="clusterN" defaultMessage="Cluster {n}" values={{ n: clIdx + 1 }} />
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'maximumChargingCurrentPerClusterLabel' })}
|
|
||||||
value={charge}
|
|
||||||
onChange={(e) => setClusterField('MaxChargingCurrent', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerClusterLabel' })}
|
|
||||||
value={discharge}
|
|
||||||
onChange={(e) => setClusterField('MaxDischargingCurrent', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'maximumChargingCurrentPerBattery' })}
|
|
||||||
name="maximumChargingCurrent"
|
|
||||||
value={formValues.maximumChargingCurrent}
|
|
||||||
onChange={handleChange}
|
|
||||||
fullWidth
|
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
name="maximumChargingCurrent"
|
||||||
|
value={formValues.maximumChargingCurrent}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerBattery' })}
|
label={
|
||||||
name="maximumDischargingCurrent"
|
<FormattedMessage
|
||||||
value={formValues.maximumDischargingCurrent}
|
id="maximumDischargingCurrent "
|
||||||
onChange={handleChange}
|
defaultMessage="Maximum Discharging Current"
|
||||||
fullWidth
|
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</>
|
name="maximumDischargingCurrent"
|
||||||
)}
|
value={formValues.maximumDischargingCurrent}
|
||||||
|
onChange={handleChange}
|
||||||
{(device === 3 || device === 4) && (
|
fullWidth
|
||||||
<>
|
/>
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
</div>
|
||||||
<FormattedMessage id="systemSettings" defaultMessage="System Settings" />
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
|
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
|
||||||
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
|
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
|
||||||
|
|
@ -717,13 +558,13 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
{/* Power input*/}
|
{/* Power input*/}
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'powerPerInverterKW' })}
|
label={intl.formatMessage({ id: 'powerW' })}
|
||||||
name="timeChargeandDischargePower"
|
name="timeChargeandDischargePower"
|
||||||
value={formValues.timeChargeandDischargePower}
|
value={formValues.timeChargeandDischargePower}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
||||||
}
|
}
|
||||||
helperText={intl.formatMessage({ id: 'perInverter' })}
|
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -794,160 +635,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* --- Growatt + Sinexcel under LoadPriority: Dynamic Pricing --- */}
|
|
||||||
{(device === 3 || device === 4) &&
|
|
||||||
OperatingPriorityOptions[formValues.operatingPriority] === 'LoadPriority' && (
|
|
||||||
<>
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
|
||||||
<FormattedMessage id="dynamicPricing" defaultMessage="Dynamic Pricing" />
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
|
|
||||||
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
|
|
||||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
|
||||||
<FormattedMessage id="dynamicPricingMode" defaultMessage="Dynamic Pricing Mode" />
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={formValues.dynamicPricingMode ?? 'Disabled'}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
dynamicPricingMode: e.target.value as string,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{DynamicPricingOptions.map((opt) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{intl.formatMessage({ id: dynamicPricingLabelKey[opt] })}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formValues.dynamicPricingMode === 'Tou' && (
|
|
||||||
<>
|
|
||||||
{(() => {
|
|
||||||
const renderTimeField = (
|
|
||||||
labelId: string,
|
|
||||||
key: 'timeToSellFrom' | 'timeToSellTo' | 'timeToBuyFrom' | 'timeToBuyTo'
|
|
||||||
) => {
|
|
||||||
const raw = formValues[key];
|
|
||||||
const parsed = raw ? dayjs(raw, 'HH:mm') : null;
|
|
||||||
return (
|
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
||||||
<TimePicker
|
|
||||||
ampm={false}
|
|
||||||
label={intl.formatMessage({ id: labelId })}
|
|
||||||
value={parsed && parsed.isValid() ? parsed : null}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: newValue ? newValue.format('HH:mm') : '',
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} sx={{ marginTop: 2, width: '100%' }} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</LocalizationProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 2, ml: 1 }}>
|
|
||||||
<FormattedMessage id="timeToSell" defaultMessage="Time to Sell" />
|
|
||||||
</Typography>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginLeft: 8 }}>
|
|
||||||
{renderTimeField('timeFrom', 'timeToSellFrom')}
|
|
||||||
{renderTimeField('timeTo', 'timeToSellTo')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 2, ml: 1 }}>
|
|
||||||
<FormattedMessage id="timeToBuy" defaultMessage="Time to Buy" />
|
|
||||||
</Typography>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginLeft: 8 }}>
|
|
||||||
{renderTimeField('timeFrom', 'timeToBuyFrom')}
|
|
||||||
{renderTimeField('timeTo', 'timeToBuyTo')}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formValues.dynamicPricingMode === 'SpotPrice' && (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'networkProvider' })}
|
|
||||||
value={
|
|
||||||
props.installation.networkProvider ||
|
|
||||||
intl.formatMessage({ id: 'networkProviderSetOnInformationTab' })
|
|
||||||
}
|
|
||||||
InputProps={{ readOnly: true }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'currentPrice' })}
|
|
||||||
value={(props.values.Config as any).CurrentPrice?.toString() ?? ''}
|
|
||||||
InputProps={{
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: <InputAdornment position="end">CHF/kWh</InputAdornment>,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'priceToSell' })}
|
|
||||||
name="priceToSell"
|
|
||||||
value={formValues.priceToSell ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === '' || /^\d*\.?\d*$/.test(v)) {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({ ...prev, priceToSell: v }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: <InputAdornment position="end">CHF/kWh</InputAdornment>,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'priceToBuy' })}
|
|
||||||
name="priceToBuy"
|
|
||||||
value={formValues.priceToBuy ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === '' || /^\d*\.?\d*$/.test(v)) {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({ ...prev, priceToBuy: v }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: <InputAdornment position="end">CHF/kWh</InputAdornment>,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -1025,4 +712,4 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SodistoreHomeConfigurationV2;
|
export default SodistoreHomeConfiguration;
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
TicketSummary,
|
TicketSummary,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
AdminUser,
|
|
||||||
categoryKeys,
|
categoryKeys,
|
||||||
subCategoryKeys,
|
subCategoryKeys,
|
||||||
getCategoryDisplayLabel,
|
getCategoryDisplayLabel,
|
||||||
|
|
@ -54,12 +53,9 @@ function TicketList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [tickets, setTickets] = useState<TicketSummary[]>([]);
|
const [tickets, setTickets] = useState<TicketSummary[]>([]);
|
||||||
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
||||||
const [partnerFilter, setPartnerFilter] = useState<string>('');
|
const [partnerFilter, setPartnerFilter] = useState<string>('');
|
||||||
const [assigneeFilter, setAssigneeFilter] = useState<string>('');
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
|
@ -72,26 +68,12 @@ function TicketList() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
axiosConfig
|
|
||||||
.get('/GetAdminUsers')
|
|
||||||
.then((res) => setAdminUsers(res.data))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const partnerOptions = Array.from(
|
const partnerOptions = Array.from(
|
||||||
new Set(tickets.map((t) => t.distributionPartner).filter((p) => p && p.trim() !== ''))
|
new Set(tickets.map((t) => t.distributionPartner).filter((p) => p && p.trim() !== ''))
|
||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
const assigneeOptions = adminUsers
|
|
||||||
.filter((u) => {
|
|
||||||
const name = (u.name ?? '').toLowerCase();
|
|
||||||
return (
|
|
||||||
!name.includes('inesco energy master admin') &&
|
|
||||||
!name.includes('paal myhre')
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
|
||||||
|
|
||||||
const filtered = tickets
|
const filtered = tickets
|
||||||
.filter((t) => {
|
.filter((t) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
|
|
@ -100,14 +82,7 @@ function TicketList() {
|
||||||
t.installationName.toLowerCase().includes(search.toLowerCase());
|
t.installationName.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status);
|
const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status);
|
||||||
const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter;
|
const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter;
|
||||||
const matchesAssignee =
|
return matchesSearch && matchesStatus && matchesPartner;
|
||||||
assigneeFilter === '' ||
|
|
||||||
(assigneeFilter === '__unassigned__'
|
|
||||||
? !t.assigneeId
|
|
||||||
: t.assigneeId === Number(assigneeFilter));
|
|
||||||
const matchesCategory =
|
|
||||||
categoryFilter === '' || t.category === Number(categoryFilter);
|
|
||||||
return matchesSearch && matchesStatus && matchesPartner && matchesAssignee && matchesCategory;
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
|
@ -201,53 +176,6 @@ function TicketList() {
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
|
||||||
<InputLabel>
|
|
||||||
<FormattedMessage id="assignee" defaultMessage="Assignee" />
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={assigneeFilter}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: 'assignee',
|
|
||||||
defaultMessage: 'Assignee'
|
|
||||||
})}
|
|
||||||
onChange={(e) => setAssigneeFilter(e.target.value as string)}
|
|
||||||
>
|
|
||||||
<MenuItem value="">
|
|
||||||
<FormattedMessage id="allAssignees" defaultMessage="All Assignees" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value="__unassigned__">
|
|
||||||
<FormattedMessage id="unassigned" defaultMessage="Unassigned" />
|
|
||||||
</MenuItem>
|
|
||||||
{assigneeOptions.map((u) => (
|
|
||||||
<MenuItem key={u.id} value={String(u.id)}>
|
|
||||||
{u.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
|
||||||
<InputLabel>
|
|
||||||
<FormattedMessage id="category" defaultMessage="Category" />
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={categoryFilter}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: 'category',
|
|
||||||
defaultMessage: 'Category'
|
|
||||||
})}
|
|
||||||
onChange={(e) => setCategoryFilter(e.target.value as string)}
|
|
||||||
>
|
|
||||||
<MenuItem value="">
|
|
||||||
<FormattedMessage id="allCategories" defaultMessage="All Categories" />
|
|
||||||
</MenuItem>
|
|
||||||
{Object.entries(categoryKeys).map(([val, msg]) => (
|
|
||||||
<MenuItem key={val} value={val}>
|
|
||||||
{intl.formatMessage(msg)}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -267,8 +267,6 @@ export type TicketSummary = {
|
||||||
distributionPartner: string;
|
distributionPartner: string;
|
||||||
customSubCategory: string | null;
|
customSubCategory: string | null;
|
||||||
customCategory: string | null;
|
customCategory: string | null;
|
||||||
assigneeId: number | null;
|
|
||||||
assigneeName: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
|
|
|
||||||
|
|
@ -521,36 +521,8 @@
|
||||||
"minimumSocPercent": "Minimaler Ladezustand (%)",
|
"minimumSocPercent": "Minimaler Ladezustand (%)",
|
||||||
"powerW": "Leistung (W)",
|
"powerW": "Leistung (W)",
|
||||||
"enterPowerValue": "Positiven oder negativen Leistungswert eingeben",
|
"enterPowerValue": "Positiven oder negativen Leistungswert eingeben",
|
||||||
"inverterNumber": "Anzahl Wechselrichter",
|
|
||||||
"batteriesCountInInverter": "Batterieanzahl in Wechselrichter {number}",
|
|
||||||
"batteryNumberInClusterN": "Batterieanzahl in Cluster {n}",
|
|
||||||
"pvStringsNumberInInverterN": "Anzahl PV-Strings in Wechselrichter {n}",
|
|
||||||
"batteries": "Batterien",
|
|
||||||
"maximumChargingCurrentPerBattery": "Maximaler Ladestrom pro Batterie (A)",
|
|
||||||
"maximumChargingCurrentPerClusterLabel": "Maximaler Ladestrom pro Cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerClusterLabel": "Maximaler Entladestrom pro Cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerBattery": "Maximaler Entladestrom pro Batterie (A)",
|
|
||||||
"powerPerInverterKW": "Leistung pro Wechselrichter (kW)",
|
|
||||||
"startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)",
|
"startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)",
|
||||||
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
|
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
|
||||||
"installationSetup": "Installationseinrichtung",
|
|
||||||
"batteryLimits": "Batteriegrenzwerte",
|
|
||||||
"systemSettings": "Systemeinstellungen",
|
|
||||||
"pvPerInverter": "PV pro Wechselrichter",
|
|
||||||
"pvInInverter": "PV in Wechselrichter {number}",
|
|
||||||
"dynamicPricing": "Dynamische Preisgestaltung",
|
|
||||||
"dynamicPricingMode": "Modus der dynamischen Preisgestaltung",
|
|
||||||
"dynamicPricingOff": "Aus",
|
|
||||||
"dynamicPricingSpotPrice": "Spot-Preis",
|
|
||||||
"dynamicPricingTou": "TOU",
|
|
||||||
"currentPrice": "Aktueller Preis",
|
|
||||||
"priceToSell": "Verkaufspreis",
|
|
||||||
"priceToBuy": "Kaufpreis",
|
|
||||||
"timeToSell": "Verkaufszeit",
|
|
||||||
"timeToBuy": "Kaufzeit",
|
|
||||||
"timeFrom": "Von",
|
|
||||||
"timeTo": "Bis",
|
|
||||||
"networkProviderSetOnInformationTab": "Im Informations-Tab festlegen",
|
|
||||||
"tourLanguageTitle": "Sprache",
|
"tourLanguageTitle": "Sprache",
|
||||||
"tourLanguageContent": "Wählen Sie Ihre bevorzugte Sprache. Die Oberfläche unterstützt Englisch, Deutsch, Französisch und Italienisch.",
|
"tourLanguageContent": "Wählen Sie Ihre bevorzugte Sprache. Die Oberfläche unterstützt Englisch, Deutsch, Französisch und Italienisch.",
|
||||||
"tourExploreTitle": "Installation erkunden",
|
"tourExploreTitle": "Installation erkunden",
|
||||||
|
|
@ -588,8 +560,6 @@
|
||||||
"category": "Kategorie",
|
"category": "Kategorie",
|
||||||
"allStatuses": "Alle Status",
|
"allStatuses": "Alle Status",
|
||||||
"allPartners": "Alle Partner",
|
"allPartners": "Alle Partner",
|
||||||
"allAssignees": "Alle Zuständigen",
|
|
||||||
"allCategories": "Alle Kategorien",
|
|
||||||
"createdAt": "Erstellt",
|
"createdAt": "Erstellt",
|
||||||
"noTickets": "Keine Tickets gefunden.",
|
"noTickets": "Keine Tickets gefunden.",
|
||||||
"backToTickets": "Zurück zu Tickets",
|
"backToTickets": "Zurück zu Tickets",
|
||||||
|
|
@ -792,7 +762,6 @@
|
||||||
"checklistStep8Sub1": "Lieferschein mit Kundenunterschrift erhalten und hochgeladen",
|
"checklistStep8Sub1": "Lieferschein mit Kundenunterschrift erhalten und hochgeladen",
|
||||||
"checklistStep10Sub1": "Installationsprotokoll hochgeladen",
|
"checklistStep10Sub1": "Installationsprotokoll hochgeladen",
|
||||||
"checklistStep10Sub2": "Zeit- und Materialbericht in Monitoring hochgeladen",
|
"checklistStep10Sub2": "Zeit- und Materialbericht in Monitoring hochgeladen",
|
||||||
"checklistStep10Sub3": "Atef kontaktieren, falls externes EMS vorhanden ist",
|
|
||||||
"checklistNoAttachments": "Noch keine Datei angehängt.",
|
"checklistNoAttachments": "Noch keine Datei angehängt.",
|
||||||
"setupProgress": "Setup-Fortschritt",
|
"setupProgress": "Setup-Fortschritt",
|
||||||
"checklistPhaseEmpty": "Nicht gestartet",
|
"checklistPhaseEmpty": "Nicht gestartet",
|
||||||
|
|
|
||||||
|
|
@ -269,36 +269,8 @@
|
||||||
"minimumSocPercent": "Minimum SoC (%)",
|
"minimumSocPercent": "Minimum SoC (%)",
|
||||||
"powerW": "Power (W)",
|
"powerW": "Power (W)",
|
||||||
"enterPowerValue": "Enter a positive or negative power value",
|
"enterPowerValue": "Enter a positive or negative power value",
|
||||||
"inverterNumber": "Inverter Number",
|
|
||||||
"batteriesCountInInverter": "Batteries Count in Inverter {number}",
|
|
||||||
"batteryNumberInClusterN": "Battery Number in Cluster {n}",
|
|
||||||
"pvStringsNumberInInverterN": "PV Strings Number in Inverter {n}",
|
|
||||||
"batteries": "batteries",
|
|
||||||
"maximumChargingCurrentPerBattery": "Maximum Charging Current per Battery (A)",
|
|
||||||
"maximumDischargingCurrentPerBattery": "Maximum Discharging Current per Battery (A)",
|
|
||||||
"maximumChargingCurrentPerClusterLabel": "Maximum Charging Current per Cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerClusterLabel": "Maximum Discharging Current per Cluster (A)",
|
|
||||||
"powerPerInverterKW": "Power per Inverter (kW)",
|
|
||||||
"startDateTime": "Start Date and Time (Start Time < Stop Time)",
|
"startDateTime": "Start Date and Time (Start Time < Stop Time)",
|
||||||
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
|
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
|
||||||
"installationSetup": "Installation Setup",
|
|
||||||
"batteryLimits": "Battery Limits",
|
|
||||||
"systemSettings": "System Settings",
|
|
||||||
"pvPerInverter": "PV per Inverter",
|
|
||||||
"pvInInverter": "PV in Inverter {number}",
|
|
||||||
"dynamicPricing": "Dynamic Pricing",
|
|
||||||
"dynamicPricingMode": "Dynamic Pricing Mode",
|
|
||||||
"dynamicPricingOff": "Off",
|
|
||||||
"dynamicPricingSpotPrice": "Spot Price",
|
|
||||||
"dynamicPricingTou": "TOU",
|
|
||||||
"currentPrice": "Current Price",
|
|
||||||
"priceToSell": "Price to Sell",
|
|
||||||
"priceToBuy": "Price to Buy",
|
|
||||||
"timeToSell": "Time to Sell",
|
|
||||||
"timeToBuy": "Time to Buy",
|
|
||||||
"timeFrom": "From",
|
|
||||||
"timeTo": "To",
|
|
||||||
"networkProviderSetOnInformationTab": "Set on Information tab",
|
|
||||||
"tourLanguageTitle": "Language",
|
"tourLanguageTitle": "Language",
|
||||||
"tourLanguageContent": "Choose your preferred language. The interface supports English, German, French, and Italian.",
|
"tourLanguageContent": "Choose your preferred language. The interface supports English, German, French, and Italian.",
|
||||||
"tourExploreTitle": "Explore an Installation",
|
"tourExploreTitle": "Explore an Installation",
|
||||||
|
|
@ -336,8 +308,6 @@
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"allStatuses": "All Statuses",
|
"allStatuses": "All Statuses",
|
||||||
"allPartners": "All Partners",
|
"allPartners": "All Partners",
|
||||||
"allAssignees": "All Assignees",
|
|
||||||
"allCategories": "All Categories",
|
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
"noTickets": "No tickets found.",
|
"noTickets": "No tickets found.",
|
||||||
"backToTickets": "Back to Tickets",
|
"backToTickets": "Back to Tickets",
|
||||||
|
|
@ -540,7 +510,6 @@
|
||||||
"checklistStep8Sub1": "Delivery receipt with customer signature received and uploaded",
|
"checklistStep8Sub1": "Delivery receipt with customer signature received and uploaded",
|
||||||
"checklistStep10Sub1": "Installation protocol uploaded",
|
"checklistStep10Sub1": "Installation protocol uploaded",
|
||||||
"checklistStep10Sub2": "Time and material report uploaded to Monitoring",
|
"checklistStep10Sub2": "Time and material report uploaded to Monitoring",
|
||||||
"checklistStep10Sub3": "Contact Atef if there is external EMS",
|
|
||||||
"checklistNoAttachments": "No file attached yet.",
|
"checklistNoAttachments": "No file attached yet.",
|
||||||
"setupProgress": "Setup Progress",
|
"setupProgress": "Setup Progress",
|
||||||
"checklistPhaseEmpty": "Not started",
|
"checklistPhaseEmpty": "Not started",
|
||||||
|
|
|
||||||
|
|
@ -521,36 +521,8 @@
|
||||||
"minimumSocPercent": "SoC minimum (%)",
|
"minimumSocPercent": "SoC minimum (%)",
|
||||||
"powerW": "Puissance (W)",
|
"powerW": "Puissance (W)",
|
||||||
"enterPowerValue": "Entrez une valeur de puissance positive ou négative",
|
"enterPowerValue": "Entrez une valeur de puissance positive ou négative",
|
||||||
"inverterNumber": "Nombre d'onduleurs",
|
|
||||||
"batteriesCountInInverter": "Nombre de batteries dans l'onduleur {number}",
|
|
||||||
"batteryNumberInClusterN": "Nombre de batteries dans le cluster {n}",
|
|
||||||
"pvStringsNumberInInverterN": "Nombre de chaînes PV dans l'onduleur {n}",
|
|
||||||
"batteries": "batteries",
|
|
||||||
"maximumChargingCurrentPerBattery": "Courant de charge maximum par batterie (A)",
|
|
||||||
"maximumChargingCurrentPerClusterLabel": "Courant de charge maximum par cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerClusterLabel": "Courant de décharge maximum par cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerBattery": "Courant de décharge maximum par batterie (A)",
|
|
||||||
"powerPerInverterKW": "Puissance par onduleur (kW)",
|
|
||||||
"startDateTime": "Date et heure de début (Début < Fin)",
|
"startDateTime": "Date et heure de début (Début < Fin)",
|
||||||
"stopDateTime": "Date et heure de fin (Début < Fin)",
|
"stopDateTime": "Date et heure de fin (Début < Fin)",
|
||||||
"installationSetup": "Configuration de l'installation",
|
|
||||||
"batteryLimits": "Limites de la batterie",
|
|
||||||
"systemSettings": "Paramètres système",
|
|
||||||
"pvPerInverter": "PV par onduleur",
|
|
||||||
"pvInInverter": "PV dans l'onduleur {number}",
|
|
||||||
"dynamicPricing": "Tarification dynamique",
|
|
||||||
"dynamicPricingMode": "Mode de tarification dynamique",
|
|
||||||
"dynamicPricingOff": "Désactivé",
|
|
||||||
"dynamicPricingSpotPrice": "Prix spot",
|
|
||||||
"dynamicPricingTou": "TOU",
|
|
||||||
"currentPrice": "Prix actuel",
|
|
||||||
"priceToSell": "Prix de vente",
|
|
||||||
"priceToBuy": "Prix d'achat",
|
|
||||||
"timeToSell": "Heure de vente",
|
|
||||||
"timeToBuy": "Heure d'achat",
|
|
||||||
"timeFrom": "De",
|
|
||||||
"timeTo": "À",
|
|
||||||
"networkProviderSetOnInformationTab": "À définir dans l'onglet Informations",
|
|
||||||
"tourLanguageTitle": "Langue",
|
"tourLanguageTitle": "Langue",
|
||||||
"tourLanguageContent": "Choisissez votre langue préférée. L'interface est disponible en anglais, allemand, français et italien.",
|
"tourLanguageContent": "Choisissez votre langue préférée. L'interface est disponible en anglais, allemand, français et italien.",
|
||||||
"tourExploreTitle": "Explorer une installation",
|
"tourExploreTitle": "Explorer une installation",
|
||||||
|
|
@ -588,8 +560,6 @@
|
||||||
"category": "Catégorie",
|
"category": "Catégorie",
|
||||||
"allStatuses": "Tous les statuts",
|
"allStatuses": "Tous les statuts",
|
||||||
"allPartners": "Tous les partenaires",
|
"allPartners": "Tous les partenaires",
|
||||||
"allAssignees": "Tous les responsables",
|
|
||||||
"allCategories": "Toutes les catégories",
|
|
||||||
"createdAt": "Créé",
|
"createdAt": "Créé",
|
||||||
"noTickets": "Aucun ticket trouvé.",
|
"noTickets": "Aucun ticket trouvé.",
|
||||||
"backToTickets": "Retour aux tickets",
|
"backToTickets": "Retour aux tickets",
|
||||||
|
|
@ -792,7 +762,6 @@
|
||||||
"checklistStep8Sub1": "Bon de livraison signé par le client reçu et téléversé",
|
"checklistStep8Sub1": "Bon de livraison signé par le client reçu et téléversé",
|
||||||
"checklistStep10Sub1": "Procès-verbal d'installation téléversé",
|
"checklistStep10Sub1": "Procès-verbal d'installation téléversé",
|
||||||
"checklistStep10Sub2": "Rapport de temps et matériaux téléversé dans Monitoring",
|
"checklistStep10Sub2": "Rapport de temps et matériaux téléversé dans Monitoring",
|
||||||
"checklistStep10Sub3": "Contacter Atef en cas d'EMS externe",
|
|
||||||
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
|
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
|
||||||
"setupProgress": "Progression installation",
|
"setupProgress": "Progression installation",
|
||||||
"checklistPhaseEmpty": "Non commencé",
|
"checklistPhaseEmpty": "Non commencé",
|
||||||
|
|
|
||||||
|
|
@ -521,36 +521,8 @@
|
||||||
"minimumSocPercent": "SoC minimo (%)",
|
"minimumSocPercent": "SoC minimo (%)",
|
||||||
"powerW": "Potenza (W)",
|
"powerW": "Potenza (W)",
|
||||||
"enterPowerValue": "Inserire un valore di potenza positivo o negativo",
|
"enterPowerValue": "Inserire un valore di potenza positivo o negativo",
|
||||||
"inverterNumber": "Numero di inverter",
|
|
||||||
"batteriesCountInInverter": "Numero di batterie nell'inverter {number}",
|
|
||||||
"batteryNumberInClusterN": "Numero di batterie nel cluster {n}",
|
|
||||||
"pvStringsNumberInInverterN": "Numero di stringhe PV nell'inverter {n}",
|
|
||||||
"batteries": "batterie",
|
|
||||||
"maximumChargingCurrentPerBattery": "Corrente massima di carica per batteria (A)",
|
|
||||||
"maximumChargingCurrentPerClusterLabel": "Corrente massima di carica per cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerClusterLabel": "Corrente massima di scarica per cluster (A)",
|
|
||||||
"maximumDischargingCurrentPerBattery": "Corrente massima di scarica per batteria (A)",
|
|
||||||
"powerPerInverterKW": "Potenza per inverter (kW)",
|
|
||||||
"startDateTime": "Data e ora di inizio (Inizio < Fine)",
|
"startDateTime": "Data e ora di inizio (Inizio < Fine)",
|
||||||
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
|
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
|
||||||
"installationSetup": "Configurazione dell'installazione",
|
|
||||||
"batteryLimits": "Limiti della batteria",
|
|
||||||
"systemSettings": "Impostazioni di sistema",
|
|
||||||
"pvPerInverter": "PV per inverter",
|
|
||||||
"pvInInverter": "PV nell'inverter {number}",
|
|
||||||
"dynamicPricing": "Prezzi dinamici",
|
|
||||||
"dynamicPricingMode": "Modalità prezzi dinamici",
|
|
||||||
"dynamicPricingOff": "Off",
|
|
||||||
"dynamicPricingSpotPrice": "Prezzo spot",
|
|
||||||
"dynamicPricingTou": "TOU",
|
|
||||||
"currentPrice": "Prezzo attuale",
|
|
||||||
"priceToSell": "Prezzo di vendita",
|
|
||||||
"priceToBuy": "Prezzo di acquisto",
|
|
||||||
"timeToSell": "Orario di vendita",
|
|
||||||
"timeToBuy": "Orario di acquisto",
|
|
||||||
"timeFrom": "Da",
|
|
||||||
"timeTo": "A",
|
|
||||||
"networkProviderSetOnInformationTab": "Imposta nella scheda Informazioni",
|
|
||||||
"tourLanguageTitle": "Lingua",
|
"tourLanguageTitle": "Lingua",
|
||||||
"tourLanguageContent": "Scegli la tua lingua preferita. L'interfaccia supporta inglese, tedesco, francese e italiano.",
|
"tourLanguageContent": "Scegli la tua lingua preferita. L'interfaccia supporta inglese, tedesco, francese e italiano.",
|
||||||
"tourExploreTitle": "Esplora un'installazione",
|
"tourExploreTitle": "Esplora un'installazione",
|
||||||
|
|
@ -588,8 +560,6 @@
|
||||||
"category": "Categoria",
|
"category": "Categoria",
|
||||||
"allStatuses": "Tutti gli stati",
|
"allStatuses": "Tutti gli stati",
|
||||||
"allPartners": "Tutti i partner",
|
"allPartners": "Tutti i partner",
|
||||||
"allAssignees": "Tutti gli assegnatari",
|
|
||||||
"allCategories": "Tutte le categorie",
|
|
||||||
"createdAt": "Creato",
|
"createdAt": "Creato",
|
||||||
"noTickets": "Nessun ticket trovato.",
|
"noTickets": "Nessun ticket trovato.",
|
||||||
"backToTickets": "Torna ai ticket",
|
"backToTickets": "Torna ai ticket",
|
||||||
|
|
@ -792,7 +762,6 @@
|
||||||
"checklistStep8Sub1": "Bolla di consegna con firma del cliente ricevuta e caricata",
|
"checklistStep8Sub1": "Bolla di consegna con firma del cliente ricevuta e caricata",
|
||||||
"checklistStep10Sub1": "Verbale di installazione caricato",
|
"checklistStep10Sub1": "Verbale di installazione caricato",
|
||||||
"checklistStep10Sub2": "Rapporto tempi e materiali caricato su Monitoring",
|
"checklistStep10Sub2": "Rapporto tempi e materiali caricato su Monitoring",
|
||||||
"checklistStep10Sub3": "Contattare Atef se è presente un EMS esterno",
|
|
||||||
"checklistNoAttachments": "Nessun file allegato.",
|
"checklistNoAttachments": "Nessun file allegato.",
|
||||||
"setupProgress": "Avanzamento installazione",
|
"setupProgress": "Avanzamento installazione",
|
||||||
"checklistPhaseEmpty": "Non avviato",
|
"checklistPhaseEmpty": "Non avviato",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue