Compare commits
8 Commits
18d47232b7
...
4a6caa9ed3
| Author | SHA1 | Date |
|---|---|---|
|
|
4a6caa9ed3 | |
|
|
3fbb2eeee0 | |
|
|
544f9602e1 | |
|
|
45a3c62609 | |
|
|
dde3b9794c | |
|
|
5bced9374b | |
|
|
52c9a42e42 | |
|
|
b8d67f7926 |
|
|
@ -2176,6 +2176,17 @@ public class Controller : ControllerBase
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (ticket.AssigneeId.HasValue && ticket.AssigneeId.Value != user.Id)
|
||||||
|
{
|
||||||
|
var assignee = Db.GetUserById(ticket.AssigneeId);
|
||||||
|
if (assignee is not null)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||||
|
catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email on create: {ex}"); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fire-and-forget AI diagnosis
|
// Fire-and-forget AI diagnosis
|
||||||
var lang = user.Language ?? "en";
|
var lang = user.Language ?? "en";
|
||||||
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
||||||
|
|
@ -2221,6 +2232,40 @@ public class Controller : ControllerBase
|
||||||
ActorId = user.Id,
|
ActorId = user.Id,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var isSolveTransition = ticket.Status == (Int32)TicketStatus.Resolved
|
||||||
|
&& existing.Status != (Int32)TicketStatus.Resolved;
|
||||||
|
var isReopenTransition = existing.Status == (Int32)TicketStatus.Resolved
|
||||||
|
&& (ticket.Status == (Int32)TicketStatus.InProgress
|
||||||
|
|| ticket.Status == (Int32)TicketStatus.Open);
|
||||||
|
|
||||||
|
if (isSolveTransition)
|
||||||
|
{
|
||||||
|
var creator = Db.GetUserById(existing.CreatedByUserId);
|
||||||
|
if (creator is not null && creator.Id != user.Id)
|
||||||
|
{
|
||||||
|
var actorName = user.Name;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await creator.SendTicketSolvedEmail(ticket, actorName); }
|
||||||
|
catch (Exception ex) { Console.WriteLine($"Failed to send ticket solved email: {ex}"); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReopenTransition && existing.AssigneeId.HasValue)
|
||||||
|
{
|
||||||
|
var assignee = Db.GetUserById(existing.AssigneeId);
|
||||||
|
if (assignee is not null && assignee.Id != user.Id)
|
||||||
|
{
|
||||||
|
var actorName = user.Name;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await assignee.SendTicketReopenedEmail(ticket, actorName); }
|
||||||
|
catch (Exception ex) { Console.WriteLine($"Failed to send ticket reopened email: {ex}"); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolutionAdded)
|
if (resolutionAdded)
|
||||||
|
|
@ -2253,7 +2298,7 @@ public class Controller : ControllerBase
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
if (assignee is not null)
|
if (assignee is not null && assignee.Id != user.Id)
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||||
|
|
@ -2321,6 +2366,35 @@ public class Controller : ControllerBase
|
||||||
ticket.UpdatedAt = DateTime.UtcNow;
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
Db.Update(ticket);
|
Db.Update(ticket);
|
||||||
|
|
||||||
|
var mentioned = (comment.MentionedUserIds ?? new List<Int64>())
|
||||||
|
.Distinct()
|
||||||
|
.Where(uid => uid != user.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var uid in mentioned)
|
||||||
|
{
|
||||||
|
Db.Create(new TicketCommentMention
|
||||||
|
{
|
||||||
|
CommentId = comment.Id,
|
||||||
|
MentionedUserId = uid,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
var mentionedUser = Db.GetUserById(uid);
|
||||||
|
if (mentionedUser is null) continue;
|
||||||
|
|
||||||
|
var actorName = user.Name;
|
||||||
|
var body = comment.Body ?? "";
|
||||||
|
var excerpt = body.Length > 300 ? body.Substring(0, 300) + "..." : body;
|
||||||
|
var ticketRef = ticket;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await mentionedUser.SendTicketMentionEmail(ticketRef, actorName, excerpt); }
|
||||||
|
catch (Exception ex) { Console.WriteLine($"Failed to send mention email: {ex}"); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ public class Installation : TreeNode
|
||||||
public String ReadRoleId { get; set; } = "";
|
public String ReadRoleId { get; set; } = "";
|
||||||
public String WriteRoleId { get; set; } = "";
|
public String WriteRoleId { get; set; } = "";
|
||||||
public Boolean TestingMode { get; set; } = false;
|
public Boolean TestingMode { get; set; } = false;
|
||||||
|
public Boolean DataCollectionEnabled { get; set; } = true;
|
||||||
public int Status { get; set; } = -1;
|
public int Status { get; set; } = -1;
|
||||||
public int Product { get; set; } = (int)ProductType.Salimax;
|
public int Product { get; set; } = (int)ProductType.Salimax;
|
||||||
public int Device { get; set; } = 0;
|
public int Device { get; set; } = 0;
|
||||||
|
|
|
||||||
|
|
@ -359,4 +359,163 @@ public static class UserMethods
|
||||||
return user.SendEmail(subject, body);
|
return user.SendEmail(subject, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Task SendTicketSolvedEmail(this User user, Ticket ticket, String solvedByName)
|
||||||
|
{
|
||||||
|
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||||
|
|
||||||
|
var (subject, body) = (user.Language ?? "en") switch
|
||||||
|
{
|
||||||
|
"de" => (
|
||||||
|
$"inesco energy – Ticket #{ticket.Id} wurde gelöst",
|
||||||
|
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||||
|
$"Ihr Ticket wurde als gelöst markiert von {solvedByName}:\n\n" +
|
||||||
|
$"Ticket: #{ticket.Id}\n" +
|
||||||
|
$"Betreff: {ticket.Subject}\n\n" +
|
||||||
|
$"Ursache:\n{ticket.RootCause}\n\n" +
|
||||||
|
$"Lösung:\n{ticket.Solution}\n\n" +
|
||||||
|
$"Falls das Problem weiterhin besteht, öffnen Sie das Ticket erneut: {ticketLink}\n\n" +
|
||||||
|
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
"fr" => (
|
||||||
|
$"inesco energy – Le ticket #{ticket.Id} a été résolu",
|
||||||
|
$"Cher/Chère {user.Name},\n\n" +
|
||||||
|
$"Votre ticket a été marqué comme résolu par {solvedByName} :\n\n" +
|
||||||
|
$"Ticket : #{ticket.Id}\n" +
|
||||||
|
$"Objet : {ticket.Subject}\n\n" +
|
||||||
|
$"Cause :\n{ticket.RootCause}\n\n" +
|
||||||
|
$"Solution :\n{ticket.Solution}\n\n" +
|
||||||
|
$"Si le problème persiste, rouvrez le ticket : {ticketLink}\n\n" +
|
||||||
|
"Cordialement,\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
"it" => (
|
||||||
|
$"inesco energy – Il ticket #{ticket.Id} è stato risolto",
|
||||||
|
$"Gentile {user.Name},\n\n" +
|
||||||
|
$"Il suo ticket è stato contrassegnato come risolto da {solvedByName}:\n\n" +
|
||||||
|
$"Ticket: #{ticket.Id}\n" +
|
||||||
|
$"Oggetto: {ticket.Subject}\n\n" +
|
||||||
|
$"Causa:\n{ticket.RootCause}\n\n" +
|
||||||
|
$"Soluzione:\n{ticket.Solution}\n\n" +
|
||||||
|
$"Se il problema persiste, riaprire il ticket: {ticketLink}\n\n" +
|
||||||
|
"Cordiali saluti,\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
$"inesco energy – Ticket #{ticket.Id} has been solved",
|
||||||
|
$"Dear {user.Name},\n\n" +
|
||||||
|
$"Your ticket has been marked as solved by {solvedByName}:\n\n" +
|
||||||
|
$"Ticket: #{ticket.Id}\n" +
|
||||||
|
$"Subject: {ticket.Subject}\n\n" +
|
||||||
|
$"Root cause:\n{ticket.RootCause}\n\n" +
|
||||||
|
$"Solution:\n{ticket.Solution}\n\n" +
|
||||||
|
$"If the issue persists, reopen the ticket: {ticketLink}\n\n" +
|
||||||
|
"Best regards,\ninesco energy Monitor"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return user.SendEmail(subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task SendTicketReopenedEmail(this User user, Ticket ticket, String reopenedByName)
|
||||||
|
{
|
||||||
|
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||||
|
var priority = (TicketPriority)ticket.Priority;
|
||||||
|
var category = (TicketCategory)ticket.Category;
|
||||||
|
|
||||||
|
var (subject, body) = (user.Language ?? "en") switch
|
||||||
|
{
|
||||||
|
"de" => (
|
||||||
|
$"inesco energy – Ticket #{ticket.Id} wurde wieder geöffnet",
|
||||||
|
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||||
|
$"Ein Ihnen zugewiesenes Ticket wurde von {reopenedByName} wieder geöffnet:\n\n" +
|
||||||
|
$"Ticket: #{ticket.Id}\n" +
|
||||||
|
$"Betreff: {ticket.Subject}\n" +
|
||||||
|
$"Priorität: {priority}\n" +
|
||||||
|
$"Kategorie: {category}\n\n" +
|
||||||
|
$"Öffnen Sie das Ticket hier: {ticketLink}\n\n" +
|
||||||
|
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
"fr" => (
|
||||||
|
$"inesco energy – Le ticket #{ticket.Id} a été rouvert",
|
||||||
|
$"Cher/Chère {user.Name},\n\n" +
|
||||||
|
$"Un ticket qui vous est attribué a été rouvert par {reopenedByName} :\n\n" +
|
||||||
|
$"Ticket : #{ticket.Id}\n" +
|
||||||
|
$"Objet : {ticket.Subject}\n" +
|
||||||
|
$"Priorité : {priority}\n" +
|
||||||
|
$"Catégorie : {category}\n\n" +
|
||||||
|
$"Ouvrir le ticket : {ticketLink}\n\n" +
|
||||||
|
"Cordialement,\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
"it" => (
|
||||||
|
$"inesco energy – Il ticket #{ticket.Id} è stato riaperto",
|
||||||
|
$"Gentile {user.Name},\n\n" +
|
||||||
|
$"Un ticket assegnato a lei è stato riaperto da {reopenedByName}:\n\n" +
|
||||||
|
$"Ticket: #{ticket.Id}\n" +
|
||||||
|
$"Oggetto: {ticket.Subject}\n" +
|
||||||
|
$"Priorità: {priority}\n" +
|
||||||
|
$"Categoria: {category}\n\n" +
|
||||||
|
$"Aprire il ticket: {ticketLink}\n\n" +
|
||||||
|
"Cordiali saluti,\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
$"inesco energy – Ticket #{ticket.Id} has been reopened",
|
||||||
|
$"Dear {user.Name},\n\n" +
|
||||||
|
$"A ticket assigned to you has been reopened by {reopenedByName}:\n\n" +
|
||||||
|
$"Ticket: #{ticket.Id}\n" +
|
||||||
|
$"Subject: {ticket.Subject}\n" +
|
||||||
|
$"Priority: {priority}\n" +
|
||||||
|
$"Category: {category}\n\n" +
|
||||||
|
$"Open the ticket: {ticketLink}\n\n" +
|
||||||
|
"Best regards,\ninesco energy Monitor"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return user.SendEmail(subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task SendTicketMentionEmail(this User user, Ticket ticket, String mentionedByName, String commentExcerpt)
|
||||||
|
{
|
||||||
|
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||||
|
|
||||||
|
var (subject, body) = (user.Language ?? "en") switch
|
||||||
|
{
|
||||||
|
"de" => (
|
||||||
|
$"inesco energy – Sie wurden in Ticket #{ticket.Id} erwähnt",
|
||||||
|
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||||
|
$"{mentionedByName} hat Sie in einem Kommentar zu Ticket #{ticket.Id} erwähnt:\n\n" +
|
||||||
|
$"Betreff: {ticket.Subject}\n\n" +
|
||||||
|
$"Kommentar:\n\"{commentExcerpt}\"\n\n" +
|
||||||
|
$"Ticket öffnen: {ticketLink}\n\n" +
|
||||||
|
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
"fr" => (
|
||||||
|
$"inesco energy – Vous avez été mentionné dans le ticket #{ticket.Id}",
|
||||||
|
$"Cher/Chère {user.Name},\n\n" +
|
||||||
|
$"{mentionedByName} vous a mentionné dans un commentaire sur le ticket #{ticket.Id} :\n\n" +
|
||||||
|
$"Objet : {ticket.Subject}\n\n" +
|
||||||
|
$"Commentaire :\n« {commentExcerpt} »\n\n" +
|
||||||
|
$"Ouvrir le ticket : {ticketLink}\n\n" +
|
||||||
|
"Cordialement,\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
"it" => (
|
||||||
|
$"inesco energy – È stato menzionato nel ticket #{ticket.Id}",
|
||||||
|
$"Gentile {user.Name},\n\n" +
|
||||||
|
$"{mentionedByName} l'ha menzionata in un commento sul ticket #{ticket.Id}:\n\n" +
|
||||||
|
$"Oggetto: {ticket.Subject}\n\n" +
|
||||||
|
$"Commento:\n\"{commentExcerpt}\"\n\n" +
|
||||||
|
$"Aprire il ticket: {ticketLink}\n\n" +
|
||||||
|
"Cordiali saluti,\ninesco energy Monitor"
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
$"inesco energy – You were mentioned in ticket #{ticket.Id}",
|
||||||
|
$"Dear {user.Name},\n\n" +
|
||||||
|
$"{mentionedByName} mentioned you in a comment on ticket #{ticket.Id}:\n\n" +
|
||||||
|
$"Subject: {ticket.Subject}\n\n" +
|
||||||
|
$"Comment:\n\"{commentExcerpt}\"\n\n" +
|
||||||
|
$"Open the ticket: {ticketLink}\n\n" +
|
||||||
|
"Best regards,\ninesco energy Monitor"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return user.SendEmail(subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -13,4 +13,6 @@ public class TicketComment
|
||||||
public Int64? AuthorId { get; set; }
|
public Int64? AuthorId { get; set; }
|
||||||
public String Body { get; set; } = "";
|
public String Body { get; set; } = "";
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[Ignore] public List<Int64> MentionedUserIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public class TicketCommentMention
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 CommentId { get; set; }
|
||||||
|
[Indexed] public Int64 MentionedUserId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,7 @@ public static partial class Db
|
||||||
public static Boolean Create(TicketComment comment) => Insert(comment);
|
public static Boolean Create(TicketComment comment) => Insert(comment);
|
||||||
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||||
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||||
|
public static Boolean Create(TicketCommentMention mention) => Insert(mention);
|
||||||
|
|
||||||
// Document storage
|
// Document storage
|
||||||
public static Boolean Create(Document document) => Insert(document);
|
public static Boolean Create(Document document) => Insert(document);
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,11 @@ public static partial class Db
|
||||||
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
|
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
|
||||||
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
|
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
|
||||||
|
|
||||||
|
// Backfill: SQLite-net adds new bool columns as nullable with NULL for existing rows.
|
||||||
|
// LINQ `.Where(i => i.DataCollectionEnabled)` translates to `WHERE ... = 1` and excludes
|
||||||
|
// NULL rows, which would silently disable ingestion for every pre-existing installation.
|
||||||
|
Connection.Execute("UPDATE Installation SET DataCollectionEnabled = 1 WHERE DataCollectionEnabled IS NULL");
|
||||||
|
|
||||||
// One-time migration: rebrand to inesco energy
|
// One-time migration: rebrand to inesco energy
|
||||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
|
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
|
||||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
|
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
|
||||||
|
|
@ -138,6 +143,7 @@ public static partial class Db
|
||||||
// Ticket system tables
|
// Ticket system tables
|
||||||
fileConnection.CreateTable<Ticket>();
|
fileConnection.CreateTable<Ticket>();
|
||||||
fileConnection.CreateTable<TicketComment>();
|
fileConnection.CreateTable<TicketComment>();
|
||||||
|
fileConnection.CreateTable<TicketCommentMention>();
|
||||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ public static class DeleteOldDataFromS3
|
||||||
{
|
{
|
||||||
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
|
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
|
||||||
var cutoffKey = cutoffTimestamp.ToString();
|
var cutoffKey = cutoffTimestamp.ToString();
|
||||||
var installations = Db.Installations.ToList();
|
var installations = Db.Installations.Where(i => i.DataCollectionEnabled).ToList();
|
||||||
|
|
||||||
Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}");
|
Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ public static class DailyIngestionService
|
||||||
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
||||||
|
|
||||||
var installations = Db.Installations
|
var installations = Db.Installations
|
||||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3 && i.DataCollectionEnabled) // Skip Growatt (device=3) and installations with data collection disabled
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var installation in installations)
|
foreach (var installation in installations)
|
||||||
|
|
@ -75,6 +75,13 @@ public static class DailyIngestionService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task IngestInstallationAsync(Int64 installationId)
|
public static async Task IngestInstallationAsync(Int64 installationId)
|
||||||
{
|
{
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null || !installation.DataCollectionEnabled)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DailyIngestion] Skipping installation {installationId} (data collection disabled).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await TryIngestFromJson(installationId);
|
await TryIngestFromJson(installationId);
|
||||||
IngestFromXlsx(installationId);
|
IngestFromXlsx(installationId);
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +95,11 @@ public static class DailyIngestionService
|
||||||
{
|
{
|
||||||
var installation = Db.GetInstallationById(installationId);
|
var installation = Db.GetInstallationById(installationId);
|
||||||
if (installation is null) return;
|
if (installation is null) return;
|
||||||
|
if (!installation.DataCollectionEnabled)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DailyIngestion] Skipping date-range ingest for installation {installationId} (data collection disabled).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var newDaily = 0;
|
var newDaily = 0;
|
||||||
var newHourly = 0;
|
var newHourly = 0;
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ public static class ReportAggregationService
|
||||||
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
||||||
|
|
||||||
var installations = Db.Installations
|
var installations = Db.Installations
|
||||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3 && i.DataCollectionEnabled) // Skip Growatt (device=3) and installations with data collection disabled
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var generated = 0;
|
var generated = 0;
|
||||||
|
|
|
||||||
|
|
@ -762,6 +762,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||||
|
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||||
|
<FormattedMessage id="dataCollectionEnabled" defaultMessage="Data Collection" />
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="dataCollectionEnabled"
|
||||||
|
value={formValues.dataCollectionEnabled === false ? 'no' : 'yes'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
dataCollectionEnabled: e.target.value === 'yes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
>
|
||||||
|
<MenuItem value="yes"><FormattedMessage id="yes" defaultMessage="Yes" /></MenuItem>
|
||||||
|
<MenuItem value="no"><FormattedMessage id="no" defaultMessage="No" /></MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>>
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
'sodistore home 9': [[1, 1]],
|
'sodistore home 9': [[1, 1]],
|
||||||
|
'sodistore home 13.5': [[2, 1]],
|
||||||
'sodistore home 18': [[2, 2]],
|
'sodistore home 18': [[2, 2]],
|
||||||
'sodistore home 27': [[2, 2], [1, 1]],
|
'sodistore home 27': [[2, 2], [1, 1]],
|
||||||
'sodistore home 36': [[2, 2], [2, 2]],
|
'sodistore home 36': [[2, 2], [2, 2]],
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,12 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by status (alarms first)
|
// Sort by status (alarms first); data-collection-disabled sinks below offline.
|
||||||
return filtered.sort((a, b) => {
|
return filtered.sort((a, b) => {
|
||||||
|
const aDisabled = a.dataCollectionEnabled === false;
|
||||||
|
const bDisabled = b.dataCollectionEnabled === false;
|
||||||
|
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
|
||||||
|
|
||||||
const a_status = a.status;
|
const a_status = a.status;
|
||||||
const b_status = b.status;
|
const b_status = b.status;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import Information from '../Information/Information';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import HistoryOfActions from '../History/History';
|
import HistoryOfActions from '../History/History';
|
||||||
import Topology from '../Topology/Topology';
|
import Topology from '../Topology/Topology';
|
||||||
|
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||||
import BatteryView from '../BatteryView/BatteryView';
|
import BatteryView from '../BatteryView/BatteryView';
|
||||||
import Configuration from '../Configuration/Configuration';
|
import Configuration from '../Configuration/Configuration';
|
||||||
import PvView from '../PvView/PvView';
|
import PvView from '../PvView/PvView';
|
||||||
|
|
@ -465,20 +466,12 @@ function Installation(props: singleInstallationProps) {
|
||||||
path={routes.live}
|
path={routes.live}
|
||||||
element={
|
element={
|
||||||
props.current_installation.product === 4 ? (
|
props.current_installation.product === 4 ? (
|
||||||
// TODO: SodistoreGrid — implement actual topology layout
|
<TopologySodistoreHome
|
||||||
<Container
|
values={values}
|
||||||
maxWidth="xl"
|
connected={connected}
|
||||||
sx={{
|
loading={loading}
|
||||||
display: 'flex',
|
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||||
justifyContent: 'center',
|
></TopologySodistoreHome>
|
||||||
alignItems: 'center',
|
|
||||||
height: '40vh'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1" color="text.secondary">
|
|
||||||
Live view coming soon
|
|
||||||
</Typography>
|
|
||||||
</Container>
|
|
||||||
) : (
|
) : (
|
||||||
<Topology
|
<Topology
|
||||||
values={values}
|
values={values}
|
||||||
|
|
|
||||||
|
|
@ -1140,7 +1140,11 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
|
||||||
'InverterRecord.PvPower',
|
'InverterRecord.PvPower',
|
||||||
'InverterRecord.Battery1Power',
|
'InverterRecord.Battery1Power',
|
||||||
'InverterRecord.Battery2Power',
|
'InverterRecord.Battery2Power',
|
||||||
'InverterRecord.ConsumptionPower'
|
'InverterRecord.ConsumptionPower',
|
||||||
|
'InverterRecord.TotalBatteryPower',
|
||||||
|
'InverterRecord.TotalPhotovoltaicPower',
|
||||||
|
'InverterRecord.TotalLoadPower',
|
||||||
|
'InverterRecord.TotalGridPower'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper function to safely get a value from a nested path
|
// Helper function to safely get a value from a nested path
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ export const getChartOptions = (
|
||||||
curve: 'smooth',
|
curve: 'smooth',
|
||||||
width: 2
|
width: 2
|
||||||
},
|
},
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
top: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
yaxis:
|
yaxis:
|
||||||
type === 'dailyoverview'
|
type === 'dailyoverview'
|
||||||
? [
|
? [
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
|
@ -31,34 +31,40 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||||
const currentLocation = useLocation();
|
const currentLocation = useLocation();
|
||||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||||
//
|
|
||||||
const sortedInstallations = [...props.installations].sort((a, b) => {
|
|
||||||
// Compare the status field of each installation and sort them based on the status.
|
|
||||||
//Installations with alarms go first
|
|
||||||
let a_status = a.status;
|
|
||||||
let b_status = b.status;
|
|
||||||
|
|
||||||
if (a_status > b_status) {
|
const sortedInstallations = useMemo(() => {
|
||||||
return -1;
|
return [...props.installations].sort((a, b) => {
|
||||||
}
|
// Data-collection-disabled installations sink below everything (even offline).
|
||||||
if (a_status < b_status) {
|
const aDisabled = a.dataCollectionEnabled === false;
|
||||||
return 1;
|
const bDisabled = b.dataCollectionEnabled === false;
|
||||||
}
|
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
|
||||||
|
|
||||||
|
// Then sort by status (alarms first)
|
||||||
|
const a_status = a.status;
|
||||||
|
const b_status = b.status;
|
||||||
|
|
||||||
|
if (a_status > b_status) return -1;
|
||||||
|
if (a_status < b_status) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
}, [props.installations]);
|
||||||
|
|
||||||
const handleSelectOneInstallation = (installationID: number): void => {
|
const handleSelectOneInstallation = (installationID: number): void => {
|
||||||
if (selectedInstallation != installationID) {
|
if (selectedInstallation != installationID) {
|
||||||
setSelectedInstallation(installationID);
|
setSelectedInstallation(installationID);
|
||||||
setSelectedInstallation(-1);
|
setSelectedInstallation(-1);
|
||||||
|
|
||||||
|
const target = props.installations.find((i) => i.id === installationID);
|
||||||
|
const landingTab =
|
||||||
|
target?.dataCollectionEnabled === false ? routes.information : routes.live;
|
||||||
|
|
||||||
navigate(
|
navigate(
|
||||||
baseRoute +
|
baseRoute +
|
||||||
routes.list +
|
routes.list +
|
||||||
routes.installation +
|
routes.installation +
|
||||||
`${installationID}` +
|
`${installationID}` +
|
||||||
'/' +
|
'/' +
|
||||||
routes.live,
|
landingTab,
|
||||||
{
|
{
|
||||||
replace: true
|
replace: true
|
||||||
}
|
}
|
||||||
|
|
@ -77,18 +83,16 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const isListView =
|
||||||
|
currentLocation.pathname === baseRoute + 'list' ||
|
||||||
|
currentLocation.pathname === baseRoute + routes.list;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={1} sx={{ marginTop: 0.1 }}>
|
<Grid container spacing={1} sx={{ marginTop: 0.1 }}>
|
||||||
<Grid
|
<Grid
|
||||||
item
|
item
|
||||||
sx={{
|
sx={{
|
||||||
display:
|
display: isListView ? 'block' : 'none'
|
||||||
currentLocation.pathname ===
|
|
||||||
baseRoute + 'list' ||
|
|
||||||
currentLocation.pathname ===
|
|
||||||
baseRoute + routes.list
|
|
||||||
? 'block'
|
|
||||||
: 'none'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -209,6 +213,18 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
marginLeft: '15px'
|
marginLeft: '15px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{installation.dataCollectionEnabled === false ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'grey'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{status === -1 ? (
|
{status === -1 ? (
|
||||||
<CancelIcon
|
<CancelIcon
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -249,6 +265,8 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
: 'green'
|
: 'green'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{installation.testingMode && (
|
{installation.testingMode && (
|
||||||
<BuildIcon
|
<BuildIcon
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
const [values, setValues] = useState<JSONRecordData | null>(null);
|
const [values, setValues] = useState<JSONRecordData | null>(null);
|
||||||
const status = props.current_installation.status;
|
const status = props.current_installation.status;
|
||||||
|
const dataCollectionDisabled = props.current_installation.dataCollectionEnabled === false;
|
||||||
const [
|
const [
|
||||||
failedToCommunicateWithInstallation,
|
failedToCommunicateWithInstallation,
|
||||||
setFailedToCommunicateWithInstallation
|
setFailedToCommunicateWithInstallation
|
||||||
|
|
@ -417,6 +418,18 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
marginTop: '-10px'
|
marginTop: '-10px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{dataCollectionDisabled ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'grey'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{status === -1 ? (
|
{status === -1 ? (
|
||||||
<CancelIcon
|
<CancelIcon
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -457,6 +470,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
: 'green'
|
: 'green'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{props.current_installation.testingMode && (
|
{props.current_installation.testingMode && (
|
||||||
<BuildIcon
|
<BuildIcon
|
||||||
|
|
@ -474,12 +489,14 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading &&
|
{loading &&
|
||||||
|
!dataCollectionDisabled &&
|
||||||
currentTab != 'information' &&
|
currentTab != 'information' &&
|
||||||
// currentTab != 'manage' &&
|
// currentTab != 'manage' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'log' &&
|
currentTab != 'log' &&
|
||||||
currentTab != 'report' &&
|
currentTab != 'report' &&
|
||||||
currentTab != 'installationTickets' && (
|
currentTab != 'installationTickets' &&
|
||||||
|
currentTab != 'documents' && (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="xl"
|
maxWidth="xl"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -521,7 +538,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{currentUser.userType !== UserType.client && (
|
{currentUser.userType !== UserType.client && !dataCollectionDisabled && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.log}
|
path={routes.log}
|
||||||
element={
|
element={
|
||||||
|
|
@ -534,6 +551,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!dataCollectionDisabled && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.live}
|
path={routes.live}
|
||||||
element={
|
element={
|
||||||
|
|
@ -545,8 +563,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
></TopologySodistoreHome>
|
></TopologySodistoreHome>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentUser.userType !== UserType.client && (
|
{currentUser.userType !== UserType.client && !dataCollectionDisabled && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.batteryview + '/*'}
|
path={routes.batteryview + '/*'}
|
||||||
element={
|
element={
|
||||||
|
|
@ -573,7 +592,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentUser.userType == UserType.admin && (
|
{currentUser.userType == UserType.admin && !dataCollectionDisabled && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.configuration}
|
path={routes.configuration}
|
||||||
element={
|
element={
|
||||||
|
|
@ -600,6 +619,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)} */}
|
)} */}
|
||||||
|
|
||||||
|
{!dataCollectionDisabled && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.overview}
|
path={routes.overview}
|
||||||
element={
|
element={
|
||||||
|
|
@ -613,8 +633,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{props.current_installation.device !== 3 && (
|
{props.current_installation.device !== 3 && !dataCollectionDisabled && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.report}
|
path={routes.report}
|
||||||
element={
|
element={
|
||||||
|
|
@ -651,7 +672,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.live}></Navigate>}
|
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
|
import {
|
||||||
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
InputAdornment,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
TextField
|
||||||
|
} from '@mui/material';
|
||||||
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
|
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
|
||||||
import { useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import routes from '../../../Resources/routes.json';
|
import routes from '../../../Resources/routes.json';
|
||||||
|
|
@ -16,9 +24,10 @@ interface installationSearchProps {
|
||||||
function InstallationSearch(props: installationSearchProps) {
|
function InstallationSearch(props: installationSearchProps) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [sortByStatus, setSortByStatus] = useState('All Installations');
|
||||||
|
const [sortByAction, setSortByAction] = useState('All Installations');
|
||||||
const currentLocation = useLocation();
|
const currentLocation = useLocation();
|
||||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||||
// const [filteredData, setFilteredData] = useState(props.installations);
|
|
||||||
|
|
||||||
const indexedData = useMemo(() => {
|
const indexedData = useMemo(() => {
|
||||||
return props.installations.map((item) => ({
|
return props.installations.map((item) => ({
|
||||||
|
|
@ -30,39 +39,63 @@ function InstallationSearch(props: installationSearchProps) {
|
||||||
}, [props.installations]);
|
}, [props.installations]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return indexedData.filter(
|
let list = indexedData.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.nameLower.includes(searchTerm.toLowerCase()) ||
|
item.nameLower.includes(searchTerm.toLowerCase()) ||
|
||||||
item.locationLower.includes(searchTerm.toLowerCase()) ||
|
item.locationLower.includes(searchTerm.toLowerCase()) ||
|
||||||
item.regionLower.includes(searchTerm.toLowerCase())
|
item.regionLower.includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
}, [searchTerm, indexedData]);
|
|
||||||
|
switch (sortByStatus) {
|
||||||
|
case 'Installations With Alarm':
|
||||||
|
list = list.filter((i) => i.status === 2);
|
||||||
|
break;
|
||||||
|
case 'Installations with Warning':
|
||||||
|
list = list.filter((i) => i.status === 1);
|
||||||
|
break;
|
||||||
|
case 'Functional Installations':
|
||||||
|
list = list.filter((i) => i.status === 0);
|
||||||
|
break;
|
||||||
|
case 'Offline Installations':
|
||||||
|
list = list.filter((i) => i.status === -1);
|
||||||
|
break;
|
||||||
|
case 'Installations Without Data Collection':
|
||||||
|
list = list.filter((i) => i.dataCollectionEnabled === false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sortByAction) {
|
||||||
|
case 'Installations With Action Flag':
|
||||||
|
list = list.filter((i) => i.testingMode === true);
|
||||||
|
break;
|
||||||
|
case 'Installations Without Action Flag':
|
||||||
|
list = list.filter((i) => i.testingMode === false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [searchTerm, indexedData, sortByStatus, sortByAction]);
|
||||||
|
|
||||||
|
const isListView =
|
||||||
|
currentLocation.pathname === baseRoute + 'list' ||
|
||||||
|
currentLocation.pathname === baseRoute + routes.list;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{isListView && (
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid
|
<Grid item xs={12}>
|
||||||
item
|
|
||||||
xs={12}
|
|
||||||
md={6}
|
|
||||||
sx={{
|
|
||||||
display:
|
|
||||||
currentLocation.pathname ===
|
|
||||||
baseRoute + 'list' ||
|
|
||||||
currentLocation.pathname ===
|
|
||||||
baseRoute + routes.list
|
|
||||||
? 'block'
|
|
||||||
: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start'
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
width: '100%',
|
||||||
|
flexWrap: 'wrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormControl variant="outlined">
|
<FormControl variant="outlined" sx={{ width: 280 }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder={intl.formatMessage({ id: 'search' })}
|
placeholder={intl.formatMessage({ id: 'search' })}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -77,9 +110,55 @@ function InstallationSearch(props: installationSearchProps) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl sx={{ width: 280 }}>
|
||||||
|
<InputLabel>
|
||||||
|
<FormattedMessage id="sortByStatus" defaultMessage="Sort By Status" />
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortByStatus}
|
||||||
|
onChange={(e) => setSortByStatus(e.target.value)}
|
||||||
|
label={intl.formatMessage({ id: 'sortByStatus' })}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
'All Installations',
|
||||||
|
'Installations With Alarm',
|
||||||
|
'Installations with Warning',
|
||||||
|
'Functional Installations',
|
||||||
|
'Offline Installations',
|
||||||
|
'Installations Without Data Collection'
|
||||||
|
].map((type) => (
|
||||||
|
<MenuItem key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl sx={{ width: 280 }}>
|
||||||
|
<InputLabel>
|
||||||
|
<FormattedMessage id="sortByActionFlag" defaultMessage="Sort By Action Flag" />
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortByAction}
|
||||||
|
onChange={(e) => setSortByAction(e.target.value)}
|
||||||
|
label={intl.formatMessage({ id: 'sortByActionFlag' })}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
'All Installations',
|
||||||
|
'Installations With Action Flag',
|
||||||
|
'Installations Without Action Flag'
|
||||||
|
].map((type) => (
|
||||||
|
<MenuItem key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
<FlatInstallationView installations={filteredData} product={props.product} />
|
<FlatInstallationView installations={filteredData} product={props.product} />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
// Helper to build form values from S3 data
|
// Helper to build form values from S3 data
|
||||||
const getS3Values = (): Partial<ConfigurationValues> => ({
|
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||||
minimumSoC: props.values.Config.MinSoc,
|
minimumSoC: props.values.Config.MinSoc,
|
||||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||||
operatingPriority: resolveOperatingPriorityIndex(
|
operatingPriority: resolveOperatingPriorityIndex(
|
||||||
props.values.Config.OperatingPriority
|
props.values.Config.OperatingPriority
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
vpnIp: '',
|
vpnIp: '',
|
||||||
installationModel: '',
|
installationModel: '',
|
||||||
externalEms: 'No',
|
externalEms: 'No',
|
||||||
|
dataCollectionEnabled: true,
|
||||||
...(isSodistorePro ? { device: 4 } : {}),
|
...(isSodistorePro ? { device: 4 } : {}),
|
||||||
});
|
});
|
||||||
const [inverterCount, setInverterCount] = useState('');
|
const [inverterCount, setInverterCount] = useState('');
|
||||||
|
|
@ -249,6 +250,46 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
marginTop: 1,
|
||||||
|
marginBottom: 1,
|
||||||
|
width: 390
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputLabel
|
||||||
|
sx={{
|
||||||
|
fontSize: 14,
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="dataCollectionEnabled"
|
||||||
|
defaultMessage="Data Collection"
|
||||||
|
/>
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="dataCollectionEnabled"
|
||||||
|
value={formValues.dataCollectionEnabled ? 'yes' : 'no'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
dataCollectionEnabled: e.target.value === 'yes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="yes">
|
||||||
|
<FormattedMessage id="yes" defaultMessage="Yes" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="no">
|
||||||
|
<FormattedMessage id="no" defaultMessage="No" />
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
const isGrowatt = currentInstallation?.device === 3
|
const isGrowatt = currentInstallation?.device === 3
|
||||||
|| (installations.length === 1 && installations[0].device === 3);
|
|| (installations.length === 1 && installations[0].device === 3);
|
||||||
|
|
||||||
|
// When data collection is disabled, only navigation, info, history, tickets, documents remain.
|
||||||
|
const dataCollectionDisabled =
|
||||||
|
currentInstallation?.dataCollectionEnabled === false
|
||||||
|
|| (installations.length === 1 && installations[0].dataCollectionEnabled === false);
|
||||||
|
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents'];
|
||||||
|
|
||||||
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -471,6 +477,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
>
|
>
|
||||||
{tabs
|
{tabs
|
||||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||||
|
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
|
@ -544,6 +551,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
>
|
>
|
||||||
{singleInstallationTabs
|
{singleInstallationTabs
|
||||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||||
|
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,20 @@ import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Chip,
|
Chip,
|
||||||
|
ClickAwayListener,
|
||||||
Divider,
|
Divider,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Paper,
|
||||||
|
Popper,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||||
import DocumentList from 'src/components/DocumentList';
|
import DocumentList from 'src/components/DocumentList';
|
||||||
|
|
@ -33,6 +38,7 @@ function CommentThread({
|
||||||
onCommentAdded,
|
onCommentAdded,
|
||||||
adminUsers = []
|
adminUsers = []
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
|
const intl = useIntl();
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -40,6 +46,68 @@ function CommentThread({
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
|
||||||
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
|
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
|
||||||
|
|
||||||
|
const mentionCandidates = mentionQuery === null
|
||||||
|
? []
|
||||||
|
: adminUsers
|
||||||
|
.filter((u) =>
|
||||||
|
!MENTION_EXCLUDED_NAMES.includes(u.name) &&
|
||||||
|
u.name.toLowerCase().includes(mentionQuery.toLowerCase()) &&
|
||||||
|
!mentionedIds.includes(u.id)
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const detectMention = (text: string, cursor: number) => {
|
||||||
|
const upToCursor = text.slice(0, cursor);
|
||||||
|
const atIdx = upToCursor.lastIndexOf('@');
|
||||||
|
if (atIdx === -1) return null;
|
||||||
|
const between = upToCursor.slice(atIdx + 1);
|
||||||
|
if (/\s/.test(between)) return null;
|
||||||
|
const prevChar = atIdx === 0 ? ' ' : upToCursor[atIdx - 1];
|
||||||
|
if (!/\s|^$/.test(prevChar) && atIdx !== 0) return null;
|
||||||
|
return { atIdx, query: between };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyChange = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||||
|
const text = e.target.value;
|
||||||
|
const cursor = e.target.selectionStart ?? text.length;
|
||||||
|
setBody(text);
|
||||||
|
const match = detectMention(text, cursor);
|
||||||
|
setMentionQuery(match ? match.query : null);
|
||||||
|
|
||||||
|
// Drop mentioned IDs whose display names no longer appear in the body
|
||||||
|
setMentionedIds((prev) =>
|
||||||
|
prev.filter((uid) => {
|
||||||
|
const u = adminUsers.find((au) => au.id === uid);
|
||||||
|
return u ? text.includes(`@${u.name}`) : false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectMention = (userId: number, userName: string) => {
|
||||||
|
const input = commentInputRef.current;
|
||||||
|
const cursor = input?.selectionStart ?? body.length;
|
||||||
|
const match = detectMention(body, cursor);
|
||||||
|
if (!match) return;
|
||||||
|
const before = body.slice(0, match.atIdx);
|
||||||
|
const after = body.slice(cursor);
|
||||||
|
const token = `@${userName} `;
|
||||||
|
const next = `${before}${token}${after}`;
|
||||||
|
setBody(next);
|
||||||
|
setMentionedIds((prev) => (prev.includes(userId) ? prev : [...prev, userId]));
|
||||||
|
setMentionQuery(null);
|
||||||
|
const caret = before.length + token.length;
|
||||||
|
setTimeout(() => {
|
||||||
|
input?.focus();
|
||||||
|
input?.setSelectionRange(caret, caret);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -64,7 +132,15 @@ function CommentThread({
|
||||||
try {
|
try {
|
||||||
let commentId: number | undefined;
|
let commentId: number | undefined;
|
||||||
if (body.trim()) {
|
if (body.trim()) {
|
||||||
const res = await axiosConfig.post('/AddTicketComment', { ticketId, body });
|
const activeMentionedIds = mentionedIds.filter((uid) => {
|
||||||
|
const u = adminUsers.find((au) => au.id === uid);
|
||||||
|
return u ? body.includes(`@${u.name}`) : false;
|
||||||
|
});
|
||||||
|
const res = await axiosConfig.post('/AddTicketComment', {
|
||||||
|
ticketId,
|
||||||
|
body,
|
||||||
|
mentionedUserIds: activeMentionedIds
|
||||||
|
});
|
||||||
commentId = res.data?.id;
|
commentId = res.data?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +166,8 @@ function CommentThread({
|
||||||
}
|
}
|
||||||
|
|
||||||
setBody('');
|
setBody('');
|
||||||
|
setMentionedIds([]);
|
||||||
|
setMentionQuery(null);
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
onCommentAdded();
|
onCommentAdded();
|
||||||
|
|
@ -166,10 +244,35 @@ function CommentThread({
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={4}
|
maxRows={4}
|
||||||
placeholder="Add a comment..."
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'mentionPlaceholder',
|
||||||
|
defaultMessage: 'Type @ to mention a user'
|
||||||
|
})}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={handleBodyChange}
|
||||||
|
inputRef={commentInputRef}
|
||||||
/>
|
/>
|
||||||
|
<Popper
|
||||||
|
open={mentionQuery !== null && mentionCandidates.length > 0}
|
||||||
|
anchorEl={commentInputRef.current}
|
||||||
|
placement="top-start"
|
||||||
|
style={{ zIndex: 1300 }}
|
||||||
|
>
|
||||||
|
<ClickAwayListener onClickAway={() => setMentionQuery(null)}>
|
||||||
|
<Paper elevation={4} sx={{ minWidth: 200, maxHeight: 240, overflowY: 'auto' }}>
|
||||||
|
<MenuList dense>
|
||||||
|
{mentionCandidates.map((u) => (
|
||||||
|
<MenuItem
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => handleSelectMention(u.id, u.name)}
|
||||||
|
>
|
||||||
|
{u.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</Paper>
|
||||||
|
</ClickAwayListener>
|
||||||
|
</Popper>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import {
|
import {
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
|
|
@ -28,7 +28,8 @@ import {
|
||||||
subCategoryLabels,
|
subCategoryLabels,
|
||||||
subCategoriesByCategory,
|
subCategoriesByCategory,
|
||||||
categoryLabels,
|
categoryLabels,
|
||||||
otherSubCategoryValues
|
otherSubCategoryValues,
|
||||||
|
AdminUser
|
||||||
} from 'src/interfaces/TicketTypes';
|
} from 'src/interfaces/TicketTypes';
|
||||||
|
|
||||||
type Installation = {
|
type Installation = {
|
||||||
|
|
@ -65,6 +66,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
|
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
|
||||||
|
const intl = useIntl();
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
||||||
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
||||||
|
|
@ -73,6 +75,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
useState<Installation | null>(null);
|
useState<Installation | null>(null);
|
||||||
const [loadingInstallations, setLoadingInstallations] = useState(false);
|
const [loadingInstallations, setLoadingInstallations] = useState(false);
|
||||||
const [priority, setPriority] = useState<number>(TicketPriority.Medium);
|
const [priority, setPriority] = useState<number>(TicketPriority.Medium);
|
||||||
|
const [assigneeId, setAssigneeId] = useState<number | ''>('');
|
||||||
|
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
|
||||||
const [category, setCategory] = useState<number>(TicketCategory.Hardware);
|
const [category, setCategory] = useState<number>(TicketCategory.Hardware);
|
||||||
const [subCategory, setSubCategory] = useState<number>(
|
const [subCategory, setSubCategory] = useState<number>(
|
||||||
TicketSubCategory.Battery
|
TicketSubCategory.Battery
|
||||||
|
|
@ -189,6 +193,16 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
.finally(() => setLoadingInstallations(false));
|
.finally(() => setLoadingInstallations(false));
|
||||||
}, [selectedProduct]);
|
}, [selectedProduct]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetAdminUsers')
|
||||||
|
.then((res) => {
|
||||||
|
if (Array.isArray(res.data)) setAdminUsers(res.data);
|
||||||
|
})
|
||||||
|
.catch(() => setAdminUsers([]));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultInstallationId == null || !open) return;
|
if (defaultInstallationId == null || !open) return;
|
||||||
axiosConfig
|
axiosConfig
|
||||||
|
|
@ -233,6 +247,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
setAllInstallations([]);
|
setAllInstallations([]);
|
||||||
setSelectedInstallation(null);
|
setSelectedInstallation(null);
|
||||||
setPriority(TicketPriority.Medium);
|
setPriority(TicketPriority.Medium);
|
||||||
|
setAssigneeId('');
|
||||||
setCategory(TicketCategory.Hardware);
|
setCategory(TicketCategory.Hardware);
|
||||||
setSubCategory(TicketSubCategory.Battery);
|
setSubCategory(TicketSubCategory.Battery);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
|
@ -244,6 +259,15 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!subject.trim()) return;
|
if (!subject.trim()) return;
|
||||||
|
if (assigneeId === '') {
|
||||||
|
setError(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'assigneeRequired',
|
||||||
|
defaultMessage: 'Please assign this ticket to someone before creating it.'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
|
@ -253,6 +277,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
description,
|
description,
|
||||||
installationId: selectedInstallation?.id ?? null,
|
installationId: selectedInstallation?.id ?? null,
|
||||||
priority,
|
priority,
|
||||||
|
assigneeId,
|
||||||
category,
|
category,
|
||||||
subCategory: isOtherCategory ? 0 : subCategory,
|
subCategory: isOtherCategory ? 0 : subCategory,
|
||||||
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
|
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
|
||||||
|
|
@ -390,6 +415,33 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth margin="dense" required error={assigneeId === ''}>
|
||||||
|
<InputLabel>
|
||||||
|
<FormattedMessage id="assignee" defaultMessage="Assignee" />
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={assigneeId}
|
||||||
|
label="Assignee"
|
||||||
|
onChange={(e) =>
|
||||||
|
setAssigneeId(e.target.value === '' ? '' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{adminUsers
|
||||||
|
.filter((u) => {
|
||||||
|
const name = (u.name ?? '').toLowerCase();
|
||||||
|
return (
|
||||||
|
!name.includes('inesco energy master admin') &&
|
||||||
|
!name.includes('paal myhre')
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((u) => (
|
||||||
|
<MenuItem key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControl fullWidth margin="dense">
|
<FormControl fullWidth margin="dense">
|
||||||
<InputLabel>
|
<InputLabel>
|
||||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ const statusCountKeys: {
|
||||||
}[] = [
|
}[] = [
|
||||||
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||||
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
||||||
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
|
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved', color: '#2e7d32' }
|
||||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
|
||||||
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function InstallationTicketsTab({ installationId }: Props) {
|
function InstallationTicketsTab({ installationId }: Props) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const statusLabels: Record<number, string> = {
|
||||||
[TicketStatus.Open]: 'Open',
|
[TicketStatus.Open]: 'Open',
|
||||||
[TicketStatus.InProgress]: 'In Progress',
|
[TicketStatus.InProgress]: 'In Progress',
|
||||||
[TicketStatus.Escalated]: 'Escalated',
|
[TicketStatus.Escalated]: 'Escalated',
|
||||||
[TicketStatus.Resolved]: 'Resolved',
|
[TicketStatus.Resolved]: 'Solved',
|
||||||
[TicketStatus.Closed]: 'Closed'
|
[TicketStatus.Closed]: 'Closed'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
|
@ -63,9 +63,7 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
||||||
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
||||||
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||||
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
|
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' }
|
||||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
|
|
||||||
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function TicketDetailPage() {
|
function TicketDetailPage() {
|
||||||
|
|
@ -90,6 +88,9 @@ function TicketDetailPage() {
|
||||||
const [savingDescription, setSavingDescription] = useState(false);
|
const [savingDescription, setSavingDescription] = useState(false);
|
||||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
||||||
|
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
||||||
|
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const solutionRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Custom "Other" editing state
|
// Custom "Other" editing state
|
||||||
const [editCustomSub, setEditCustomSub] = useState('');
|
const [editCustomSub, setEditCustomSub] = useState('');
|
||||||
|
|
@ -153,9 +154,7 @@ function TicketDetailPage() {
|
||||||
newStatus === TicketStatus.Resolved &&
|
newStatus === TicketStatus.Resolved &&
|
||||||
(!rootCause.trim() || !solution.trim())
|
(!rootCause.trim() || !solution.trim())
|
||||||
) {
|
) {
|
||||||
setResolutionError(
|
setSolveGateOpen(true);
|
||||||
'Root Cause and Solution are required to resolve a ticket.'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResolutionError('');
|
setResolutionError('');
|
||||||
|
|
@ -475,6 +474,7 @@ function TicketDetailPage() {
|
||||||
error={
|
error={
|
||||||
!!resolutionError && !rootCause.trim()
|
!!resolutionError && !rootCause.trim()
|
||||||
}
|
}
|
||||||
|
inputRef={rootCauseRef}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={
|
||||||
|
|
@ -491,6 +491,7 @@ function TicketDetailPage() {
|
||||||
error={
|
error={
|
||||||
!!resolutionError && !solution.trim()
|
!!resolutionError && !solution.trim()
|
||||||
}
|
}
|
||||||
|
inputRef={solutionRef}
|
||||||
/>
|
/>
|
||||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||||
{resolutionSaved && (
|
{resolutionSaved && (
|
||||||
|
|
@ -591,7 +592,15 @@ function TicketDetailPage() {
|
||||||
/>
|
/>
|
||||||
</em>
|
</em>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{adminUsers.map((u) => (
|
{adminUsers
|
||||||
|
.filter((u) => {
|
||||||
|
const name = (u.name ?? '').toLowerCase();
|
||||||
|
return (
|
||||||
|
!name.includes('inesco energy master admin') &&
|
||||||
|
!name.includes('paal myhre')
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((u) => (
|
||||||
<MenuItem key={u.id} value={u.id}>
|
<MenuItem key={u.id} value={u.id}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
@ -869,6 +878,36 @@ function TicketDetailPage() {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Solve-gate reminder dialog */}
|
||||||
|
<Dialog open={solveGateOpen} onClose={() => setSolveGateOpen(false)}>
|
||||||
|
<DialogTitle>
|
||||||
|
<FormattedMessage
|
||||||
|
id="solveGateTitle"
|
||||||
|
defaultMessage="Root Cause and Solution required"
|
||||||
|
/>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
<FormattedMessage
|
||||||
|
id="solveGateBody"
|
||||||
|
defaultMessage="To mark this ticket as Solved, please fill in both Root Cause and Solution before saving."
|
||||||
|
/>
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
setSolveGateOpen(false);
|
||||||
|
if (!rootCause.trim()) rootCauseRef.current?.focus();
|
||||||
|
else solutionRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="solveGateOk" defaultMessage="OK" />
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</Container>
|
</Container>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ import StatusChip from './StatusChip';
|
||||||
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
||||||
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||||
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
|
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Solved' }
|
||||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
|
|
||||||
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
|
|
|
||||||
|
|
@ -42,21 +42,21 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
const inv = props.values?.InverterRecord;
|
const inv = props.values?.InverterRecord;
|
||||||
const hasDevices = !!inv?.Devices;
|
const hasDevices = !!inv?.Devices;
|
||||||
|
|
||||||
|
const growattActiveIndices: number[] = hasDevices
|
||||||
|
? []
|
||||||
|
: Array.from({ length: props.batteryClusterNumber }, (_, i) => i + 1)
|
||||||
|
.filter((i) => Number(inv?.[`Battery${i}Voltage`]) > 0);
|
||||||
|
|
||||||
const totalBatteryPower: number = hasDevices
|
const totalBatteryPower: number = hasDevices
|
||||||
? (inv?.TotalBatteryPower ?? 0)
|
? (inv?.TotalBatteryPower ?? 0)
|
||||||
: Number(
|
: growattActiveIndices.reduce(
|
||||||
Array.from({ length: props.batteryClusterNumber }).reduce(
|
(sum, i) => sum + (Number(inv?.[`Battery${i}Power`]) || 0),
|
||||||
(sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0),
|
|
||||||
0
|
0
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pvPower: number = hasDevices
|
const pvPower: number = hasDevices
|
||||||
? (inv?.TotalPhotovoltaicPower ?? 0)
|
? (inv?.TotalPhotovoltaicPower ?? 0)
|
||||||
: (inv?.PvPower ??
|
: (inv?.PvPower ?? 0);
|
||||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
|
||||||
.map((key) => inv?.[key] ?? 0)
|
|
||||||
.reduce((sum, val) => sum + val, 0));
|
|
||||||
|
|
||||||
const totalLoadPower: number = hasDevices
|
const totalLoadPower: number = hasDevices
|
||||||
? (inv?.TotalLoadPower ?? 0)
|
? (inv?.TotalLoadPower ?? 0)
|
||||||
|
|
@ -65,6 +65,15 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
const totalGridPower: number =
|
const totalGridPower: number =
|
||||||
inv?.TotalGridPower ?? inv?.GridPower ?? 0;
|
inv?.TotalGridPower ?? inv?.GridPower ?? 0;
|
||||||
|
|
||||||
|
const avgBatterySoc: number = hasDevices
|
||||||
|
? (inv?.AvgBatterySoc ?? 0)
|
||||||
|
: (growattActiveIndices.length
|
||||||
|
? growattActiveIndices.reduce(
|
||||||
|
(sum, i) => sum + (Number(inv?.[`Battery${i}Soc`]) || 0),
|
||||||
|
0
|
||||||
|
) / growattActiveIndices.length
|
||||||
|
: 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
|
|
@ -255,33 +264,13 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
isFirst={false}
|
isFirst={false}
|
||||||
/>
|
/>
|
||||||
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
|
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
|
||||||
{Array.from({ length: props.batteryClusterNumber }).map((_, index) => {
|
|
||||||
let soc: number;
|
|
||||||
let power: number;
|
|
||||||
|
|
||||||
if (hasDevices) {
|
|
||||||
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
|
|
||||||
const deviceId = String(Math.floor(index / 2) + 1);
|
|
||||||
const batteryIndex = (index % 2) + 1;
|
|
||||||
const device = inv?.Devices?.[deviceId];
|
|
||||||
soc = device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0;
|
|
||||||
power = device?.[`Battery${batteryIndex}Power`] ?? 0;
|
|
||||||
} else {
|
|
||||||
// Growatt: flat Battery1, Battery2, ...
|
|
||||||
const i = index + 1;
|
|
||||||
soc = Number(inv?.[`Battery${i}Soc`]) || 0;
|
|
||||||
power = Number(inv?.[`Battery${i}Power`]) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TopologyColumn
|
<TopologyColumn
|
||||||
key={index + 1}
|
|
||||||
centerBox={{
|
centerBox={{
|
||||||
title: `Battery C${index + 1}`,
|
title: 'Battery',
|
||||||
data: inv
|
data: inv
|
||||||
? [
|
? [
|
||||||
{ value: soc, unit: '%' },
|
{ value: avgBatterySoc, unit: '%' },
|
||||||
{ value: power, unit: 'W' }
|
{ value: totalBatteryPower, unit: 'W' }
|
||||||
]
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
connected: true
|
connected: true
|
||||||
|
|
@ -289,8 +278,6 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
||||||
isFirst={false}
|
isFirst={false}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -257,14 +257,11 @@ function TopologyBox(props: TopologyBoxProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.data.map((boxData, index) => {
|
{props.data.map((boxData, index) => {
|
||||||
|
const formatted = formatPower(boxData.value, boxData.unit);
|
||||||
return (
|
return (
|
||||||
<Typography key={index}>
|
<Typography key={index}>
|
||||||
{formatPower(boxData.value, boxData.unit) === 0
|
{formatted === 0 ? '0 ' : formatted}
|
||||||
? null
|
{boxData.unit}
|
||||||
: formatPower(boxData.value, boxData.unit)}
|
|
||||||
{formatPower(boxData.value, boxData.unit) === 0
|
|
||||||
? null
|
|
||||||
: boxData.unit}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,18 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
||||||
|
|
||||||
{props.node.type === 'Installation' && (
|
{props.node.type === 'Installation' && (
|
||||||
<div>
|
<div>
|
||||||
|
{(props.node as any).dataCollectionEnabled === false ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
marginLeft: '17px',
|
||||||
|
backgroundColor: 'grey'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{status === -1 ? (
|
{status === -1 ? (
|
||||||
<CancelIcon
|
<CancelIcon
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -204,6 +216,8 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
||||||
: 'green'
|
: 'green'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,16 @@ function InstallationTree() {
|
||||||
useContext(InstallationsContext);
|
useContext(InstallationsContext);
|
||||||
|
|
||||||
const sortedInstallations = [...foldersAndInstallations].sort((a, b) => {
|
const sortedInstallations = [...foldersAndInstallations].sort((a, b) => {
|
||||||
// Compare the status field of each installation and sort them based on the status.
|
// Folders stay on top (existing behavior).
|
||||||
//Installations with alarms go first
|
|
||||||
|
|
||||||
if (a.type == 'Folder') {
|
if (a.type == 'Folder') {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
// Data-collection-disabled installations sink below everything (even offline).
|
||||||
|
const aDisabled = (a as any).dataCollectionEnabled === false;
|
||||||
|
const bDisabled = (b as any).dataCollectionEnabled === false;
|
||||||
|
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
|
||||||
|
|
||||||
|
// Then sort by status (alarms first).
|
||||||
let a_status = a.status;
|
let a_status = a.status;
|
||||||
let b_status = b.status;
|
let b_status = b.status;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export interface I_Installation extends I_S3Credentials {
|
||||||
product: number;
|
product: number;
|
||||||
device: number;
|
device: number;
|
||||||
testingMode?: boolean;
|
testingMode?: boolean;
|
||||||
|
dataCollectionEnabled?: boolean;
|
||||||
status?: number;
|
status?: number;
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
networkProvider: string;
|
networkProvider: string;
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@
|
||||||
"externalEmsOther": "Externes EMS (angeben)",
|
"externalEmsOther": "Externes EMS (angeben)",
|
||||||
"emsNo": "Nein",
|
"emsNo": "Nein",
|
||||||
"emsOther": "Andere",
|
"emsOther": "Andere",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein",
|
||||||
|
"dataCollectionEnabled": "Datenerfassung",
|
||||||
"generalInfo": "Allgemeine Informationen",
|
"generalInfo": "Allgemeine Informationen",
|
||||||
"installationSetup": "Installationseinrichtung",
|
"installationSetup": "Installationseinrichtung",
|
||||||
"couplingType": "AC/DC-Kopplung",
|
"couplingType": "AC/DC-Kopplung",
|
||||||
|
|
@ -574,6 +577,7 @@
|
||||||
"resolvedAt": "Gelöst",
|
"resolvedAt": "Gelöst",
|
||||||
"noDescription": "Keine Beschreibung vorhanden.",
|
"noDescription": "Keine Beschreibung vorhanden.",
|
||||||
"assignee": "Zuständig",
|
"assignee": "Zuständig",
|
||||||
|
"assigneeRequired": "Bitte weisen Sie dieses Ticket jemandem zu, bevor Sie es erstellen.",
|
||||||
"unassigned": "Nicht zugewiesen",
|
"unassigned": "Nicht zugewiesen",
|
||||||
"deleteTicket": "Löschen",
|
"deleteTicket": "Löschen",
|
||||||
"confirmDeleteTicket": "Ticket löschen?",
|
"confirmDeleteTicket": "Ticket löschen?",
|
||||||
|
|
@ -594,6 +598,11 @@
|
||||||
"statusEscalated": "Eskaliert",
|
"statusEscalated": "Eskaliert",
|
||||||
"statusResolved": "Gelöst",
|
"statusResolved": "Gelöst",
|
||||||
"statusClosed": "Geschlossen",
|
"statusClosed": "Geschlossen",
|
||||||
|
"solveGateTitle": "Ursache und Lösung erforderlich",
|
||||||
|
"solveGateBody": "Um dieses Ticket als gelöst zu markieren, bitte sowohl Ursache als auch Lösung ausfüllen, bevor Sie speichern.",
|
||||||
|
"solveGateOk": "OK",
|
||||||
|
"mentionPlaceholder": "@ eingeben, um einen Benutzer zu erwähnen",
|
||||||
|
"mentionNoResults": "Keine Benutzer gefunden",
|
||||||
"priorityCritical": "Kritisch",
|
"priorityCritical": "Kritisch",
|
||||||
"priorityHigh": "Hoch",
|
"priorityHigh": "Hoch",
|
||||||
"priorityMedium": "Mittel",
|
"priorityMedium": "Mittel",
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@
|
||||||
"externalEmsOther": "External EMS (specify)",
|
"externalEmsOther": "External EMS (specify)",
|
||||||
"emsNo": "No",
|
"emsNo": "No",
|
||||||
"emsOther": "Other",
|
"emsOther": "Other",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"dataCollectionEnabled": "Data Collection",
|
||||||
"generalInfo": "General Info",
|
"generalInfo": "General Info",
|
||||||
"installationSetup": "Installation Setup",
|
"installationSetup": "Installation Setup",
|
||||||
"couplingType": "AC/DC Coupling",
|
"couplingType": "AC/DC Coupling",
|
||||||
|
|
@ -322,6 +325,7 @@
|
||||||
"resolvedAt": "Resolved",
|
"resolvedAt": "Resolved",
|
||||||
"noDescription": "No description provided.",
|
"noDescription": "No description provided.",
|
||||||
"assignee": "Assignee",
|
"assignee": "Assignee",
|
||||||
|
"assigneeRequired": "Please assign this ticket to someone before creating it.",
|
||||||
"unassigned": "Unassigned",
|
"unassigned": "Unassigned",
|
||||||
"deleteTicket": "Delete",
|
"deleteTicket": "Delete",
|
||||||
"confirmDeleteTicket": "Delete Ticket?",
|
"confirmDeleteTicket": "Delete Ticket?",
|
||||||
|
|
@ -340,8 +344,13 @@
|
||||||
"statusOpen": "Open",
|
"statusOpen": "Open",
|
||||||
"statusInProgress": "In Progress",
|
"statusInProgress": "In Progress",
|
||||||
"statusEscalated": "Escalated",
|
"statusEscalated": "Escalated",
|
||||||
"statusResolved": "Resolved",
|
"statusResolved": "Solved",
|
||||||
"statusClosed": "Closed",
|
"statusClosed": "Closed",
|
||||||
|
"solveGateTitle": "Root Cause and Solution required",
|
||||||
|
"solveGateBody": "To mark this ticket as Solved, please fill in both Root Cause and Solution before saving.",
|
||||||
|
"solveGateOk": "OK",
|
||||||
|
"mentionPlaceholder": "Type @ to mention a user",
|
||||||
|
"mentionNoResults": "No users found",
|
||||||
"priorityCritical": "Critical",
|
"priorityCritical": "Critical",
|
||||||
"priorityHigh": "High",
|
"priorityHigh": "High",
|
||||||
"priorityMedium": "Medium",
|
"priorityMedium": "Medium",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@
|
||||||
"externalEmsOther": "EMS externe (préciser)",
|
"externalEmsOther": "EMS externe (préciser)",
|
||||||
"emsNo": "Non",
|
"emsNo": "Non",
|
||||||
"emsOther": "Autre",
|
"emsOther": "Autre",
|
||||||
|
"yes": "Oui",
|
||||||
|
"no": "Non",
|
||||||
|
"dataCollectionEnabled": "Collecte de données",
|
||||||
"generalInfo": "Informations générales",
|
"generalInfo": "Informations générales",
|
||||||
"installationSetup": "Configuration de l'installation",
|
"installationSetup": "Configuration de l'installation",
|
||||||
"couplingType": "Couplage AC/DC",
|
"couplingType": "Couplage AC/DC",
|
||||||
|
|
@ -574,6 +577,7 @@
|
||||||
"resolvedAt": "Résolu",
|
"resolvedAt": "Résolu",
|
||||||
"noDescription": "Aucune description fournie.",
|
"noDescription": "Aucune description fournie.",
|
||||||
"assignee": "Responsable",
|
"assignee": "Responsable",
|
||||||
|
"assigneeRequired": "Veuillez assigner ce ticket à quelqu'un avant de le créer.",
|
||||||
"unassigned": "Non assigné",
|
"unassigned": "Non assigné",
|
||||||
"deleteTicket": "Supprimer",
|
"deleteTicket": "Supprimer",
|
||||||
"confirmDeleteTicket": "Supprimer le ticket ?",
|
"confirmDeleteTicket": "Supprimer le ticket ?",
|
||||||
|
|
@ -594,6 +598,11 @@
|
||||||
"statusEscalated": "Escaladé",
|
"statusEscalated": "Escaladé",
|
||||||
"statusResolved": "Résolu",
|
"statusResolved": "Résolu",
|
||||||
"statusClosed": "Fermé",
|
"statusClosed": "Fermé",
|
||||||
|
"solveGateTitle": "Cause et solution requises",
|
||||||
|
"solveGateBody": "Pour marquer ce ticket comme résolu, veuillez renseigner la cause et la solution avant d'enregistrer.",
|
||||||
|
"solveGateOk": "OK",
|
||||||
|
"mentionPlaceholder": "Tapez @ pour mentionner un utilisateur",
|
||||||
|
"mentionNoResults": "Aucun utilisateur trouvé",
|
||||||
"priorityCritical": "Critique",
|
"priorityCritical": "Critique",
|
||||||
"priorityHigh": "Élevée",
|
"priorityHigh": "Élevée",
|
||||||
"priorityMedium": "Moyenne",
|
"priorityMedium": "Moyenne",
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@
|
||||||
"externalEmsOther": "EMS esterno (specificare)",
|
"externalEmsOther": "EMS esterno (specificare)",
|
||||||
"emsNo": "No",
|
"emsNo": "No",
|
||||||
"emsOther": "Altro",
|
"emsOther": "Altro",
|
||||||
|
"yes": "Sì",
|
||||||
|
"no": "No",
|
||||||
|
"dataCollectionEnabled": "Raccolta dati",
|
||||||
"generalInfo": "Informazioni generali",
|
"generalInfo": "Informazioni generali",
|
||||||
"installationSetup": "Configurazione installazione",
|
"installationSetup": "Configurazione installazione",
|
||||||
"couplingType": "Accoppiamento AC/DC",
|
"couplingType": "Accoppiamento AC/DC",
|
||||||
|
|
@ -574,6 +577,7 @@
|
||||||
"resolvedAt": "Risolto",
|
"resolvedAt": "Risolto",
|
||||||
"noDescription": "Nessuna descrizione fornita.",
|
"noDescription": "Nessuna descrizione fornita.",
|
||||||
"assignee": "Assegnatario",
|
"assignee": "Assegnatario",
|
||||||
|
"assigneeRequired": "Assegna questo ticket a qualcuno prima di crearlo.",
|
||||||
"unassigned": "Non assegnato",
|
"unassigned": "Non assegnato",
|
||||||
"deleteTicket": "Elimina",
|
"deleteTicket": "Elimina",
|
||||||
"confirmDeleteTicket": "Eliminare il ticket?",
|
"confirmDeleteTicket": "Eliminare il ticket?",
|
||||||
|
|
@ -594,6 +598,11 @@
|
||||||
"statusEscalated": "Escalato",
|
"statusEscalated": "Escalato",
|
||||||
"statusResolved": "Risolto",
|
"statusResolved": "Risolto",
|
||||||
"statusClosed": "Chiuso",
|
"statusClosed": "Chiuso",
|
||||||
|
"solveGateTitle": "Causa e soluzione richieste",
|
||||||
|
"solveGateBody": "Per contrassegnare questo ticket come risolto, compilare sia la causa sia la soluzione prima di salvare.",
|
||||||
|
"solveGateOk": "OK",
|
||||||
|
"mentionPlaceholder": "Digita @ per menzionare un utente",
|
||||||
|
"mentionNoResults": "Nessun utente trovato",
|
||||||
"priorityCritical": "Critica",
|
"priorityCritical": "Critica",
|
||||||
"priorityHigh": "Alta",
|
"priorityHigh": "Alta",
|
||||||
"priorityMedium": "Media",
|
"priorityMedium": "Media",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue