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
|
||||
});
|
||||
|
||||
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
|
||||
var lang = user.Language ?? "en";
|
||||
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
||||
|
|
@ -2221,6 +2232,40 @@ public class Controller : ControllerBase
|
|||
ActorId = user.Id,
|
||||
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)
|
||||
|
|
@ -2253,7 +2298,7 @@ public class Controller : ControllerBase
|
|||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (assignee is not null)
|
||||
if (assignee is not null && assignee.Id != user.Id)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||
|
|
@ -2321,6 +2366,35 @@ public class Controller : ControllerBase
|
|||
ticket.UpdatedAt = DateTime.UtcNow;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ public class Installation : TreeNode
|
|||
public String ReadRoleId { get; set; } = "";
|
||||
public String WriteRoleId { get; set; } = "";
|
||||
public Boolean TestingMode { get; set; } = false;
|
||||
public Boolean DataCollectionEnabled { get; set; } = true;
|
||||
public int Status { get; set; } = -1;
|
||||
public int Product { get; set; } = (int)ProductType.Salimax;
|
||||
public int Device { get; set; } = 0;
|
||||
|
|
|
|||
|
|
@ -359,4 +359,163 @@ public static class UserMethods
|
|||
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 String Body { get; set; } = "";
|
||||
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(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||
public static Boolean Create(TicketCommentMention mention) => Insert(mention);
|
||||
|
||||
// Document storage
|
||||
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 = '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
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
|
||||
|
|
@ -138,6 +143,7 @@ public static partial class Db
|
|||
// Ticket system tables
|
||||
fileConnection.CreateTable<Ticket>();
|
||||
fileConnection.CreateTable<TicketComment>();
|
||||
fileConnection.CreateTable<TicketCommentMention>();
|
||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public static class DeleteOldDataFromS3
|
|||
{
|
||||
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
|
||||
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}");
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ public static class DailyIngestionService
|
|||
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
||||
.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();
|
||||
|
||||
foreach (var installation in installations)
|
||||
|
|
@ -75,6 +75,13 @@ public static class DailyIngestionService
|
|||
/// </summary>
|
||||
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);
|
||||
IngestFromXlsx(installationId);
|
||||
}
|
||||
|
|
@ -88,6 +95,11 @@ public static class DailyIngestionService
|
|||
{
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
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 newHourly = 0;
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ public static class ReportAggregationService
|
|||
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
||||
.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();
|
||||
|
||||
var generated = 0;
|
||||
|
|
|
|||
|
|
@ -762,6 +762,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
/>
|
||||
</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>
|
||||
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>>
|
|||
'sodistore home 18': [[4]],
|
||||
},
|
||||
4: {
|
||||
'sodistore home 9': [[1, 1]],
|
||||
'sodistore home 18': [[2, 2]],
|
||||
'sodistore home 27': [[2, 2], [1, 1]],
|
||||
'sodistore home 36': [[2, 2], [2, 2]],
|
||||
'sodistore home 9': [[1, 1]],
|
||||
'sodistore home 13.5': [[2, 1]],
|
||||
'sodistore home 18': [[2, 2]],
|
||||
'sodistore home 27': [[2, 2], [1, 1]],
|
||||
'sodistore home 36': [[2, 2], [2, 2]],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -96,8 +96,12 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
break;
|
||||
}
|
||||
|
||||
// Sort by status (alarms first)
|
||||
// Sort by status (alarms first); data-collection-disabled sinks below offline.
|
||||
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 b_status = b.status;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import Information from '../Information/Information';
|
|||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import HistoryOfActions from '../History/History';
|
||||
import Topology from '../Topology/Topology';
|
||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||
import BatteryView from '../BatteryView/BatteryView';
|
||||
import Configuration from '../Configuration/Configuration';
|
||||
import PvView from '../PvView/PvView';
|
||||
|
|
@ -465,20 +466,12 @@ function Installation(props: singleInstallationProps) {
|
|||
path={routes.live}
|
||||
element={
|
||||
props.current_installation.product === 4 ? (
|
||||
// TODO: SodistoreGrid — implement actual topology layout
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '40vh'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Live view coming soon
|
||||
</Typography>
|
||||
</Container>
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
) : (
|
||||
<Topology
|
||||
values={values}
|
||||
|
|
|
|||
|
|
@ -1140,7 +1140,11 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
|
|||
'InverterRecord.PvPower',
|
||||
'InverterRecord.Battery1Power',
|
||||
'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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ export const getChartOptions = (
|
|||
curve: 'smooth',
|
||||
width: 2
|
||||
},
|
||||
grid: {
|
||||
padding: {
|
||||
top: 30
|
||||
}
|
||||
},
|
||||
yaxis:
|
||||
type === 'dailyoverview'
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CircularProgress,
|
||||
|
|
@ -31,34 +31,40 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
const currentLocation = useLocation();
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
//
|
||||
const sortedInstallations = [...props.installations].sort((a, b) => {
|
||||
// Compare the status field of each installation and sort them based on the status.
|
||||
//Installations with alarms go first
|
||||
let a_status = a.status;
|
||||
let b_status = b.status;
|
||||
|
||||
if (a_status > b_status) {
|
||||
return -1;
|
||||
}
|
||||
if (a_status < b_status) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const sortedInstallations = useMemo(() => {
|
||||
return [...props.installations].sort((a, b) => {
|
||||
// Data-collection-disabled installations sink below everything (even offline).
|
||||
const aDisabled = a.dataCollectionEnabled === false;
|
||||
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;
|
||||
});
|
||||
}, [props.installations]);
|
||||
|
||||
const handleSelectOneInstallation = (installationID: number): void => {
|
||||
if (selectedInstallation != installationID) {
|
||||
setSelectedInstallation(installationID);
|
||||
setSelectedInstallation(-1);
|
||||
|
||||
const target = props.installations.find((i) => i.id === installationID);
|
||||
const landingTab =
|
||||
target?.dataCollectionEnabled === false ? routes.information : routes.live;
|
||||
|
||||
navigate(
|
||||
baseRoute +
|
||||
routes.list +
|
||||
routes.installation +
|
||||
`${installationID}` +
|
||||
'/' +
|
||||
routes.live,
|
||||
landingTab,
|
||||
{
|
||||
replace: true
|
||||
}
|
||||
|
|
@ -77,18 +83,16 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
}
|
||||
}));
|
||||
|
||||
const isListView =
|
||||
currentLocation.pathname === baseRoute + 'list' ||
|
||||
currentLocation.pathname === baseRoute + routes.list;
|
||||
|
||||
return (
|
||||
<Grid container spacing={1} sx={{ marginTop: 0.1 }}>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
display:
|
||||
currentLocation.pathname ===
|
||||
baseRoute + 'list' ||
|
||||
currentLocation.pathname ===
|
||||
baseRoute + routes.list
|
||||
? 'block'
|
||||
: 'none'
|
||||
display: isListView ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
|
|
@ -209,46 +213,60 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
marginLeft: '15px'
|
||||
}}
|
||||
>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
{installation.dataCollectionEnabled === false ? (
|
||||
<div
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'grey'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{installation.testingMode && (
|
||||
<BuildIcon
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
const [values, setValues] = useState<JSONRecordData | null>(null);
|
||||
const status = props.current_installation.status;
|
||||
const dataCollectionDisabled = props.current_installation.dataCollectionEnabled === false;
|
||||
const [
|
||||
failedToCommunicateWithInstallation,
|
||||
setFailedToCommunicateWithInstallation
|
||||
|
|
@ -417,46 +418,60 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
marginTop: '-10px'
|
||||
}}
|
||||
>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
{dataCollectionDisabled ? (
|
||||
<div
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'grey'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.current_installation.testingMode && (
|
||||
<BuildIcon
|
||||
|
|
@ -474,12 +489,14 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
</div>
|
||||
{loading &&
|
||||
!dataCollectionDisabled &&
|
||||
currentTab != 'information' &&
|
||||
// currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'report' &&
|
||||
currentTab != 'installationTickets' && (
|
||||
currentTab != 'installationTickets' &&
|
||||
currentTab != 'documents' && (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
|
|
@ -521,7 +538,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
/>
|
||||
|
||||
{currentUser.userType !== UserType.client && (
|
||||
{currentUser.userType !== UserType.client && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.log}
|
||||
element={
|
||||
|
|
@ -534,19 +551,21 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={routes.live}
|
||||
element={
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
}
|
||||
/>
|
||||
{!dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.live}
|
||||
element={
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType !== UserType.client && (
|
||||
{currentUser.userType !== UserType.client && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.batteryview + '/*'}
|
||||
element={
|
||||
|
|
@ -573,7 +592,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{currentUser.userType == UserType.admin && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.configuration}
|
||||
element={
|
||||
|
|
@ -600,21 +619,23 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)} */}
|
||||
|
||||
<Route
|
||||
path={routes.overview}
|
||||
element={
|
||||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
product={props.current_installation.product}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.overview}
|
||||
element={
|
||||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
product={props.current_installation.product}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.current_installation.device !== 3 && (
|
||||
{props.current_installation.device !== 3 && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.report}
|
||||
element={
|
||||
|
|
@ -651,7 +672,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.live}></Navigate>}
|
||||
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>}
|
||||
/>
|
||||
</Routes>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
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 { useIntl } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
|
|
@ -16,9 +24,10 @@ interface installationSearchProps {
|
|||
function InstallationSearch(props: installationSearchProps) {
|
||||
const intl = useIntl();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortByStatus, setSortByStatus] = useState('All Installations');
|
||||
const [sortByAction, setSortByAction] = useState('All Installations');
|
||||
const currentLocation = useLocation();
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
// const [filteredData, setFilteredData] = useState(props.installations);
|
||||
|
||||
const indexedData = useMemo(() => {
|
||||
return props.installations.map((item) => ({
|
||||
|
|
@ -30,56 +39,126 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
}, [props.installations]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return indexedData.filter(
|
||||
let list = indexedData.filter(
|
||||
(item) =>
|
||||
item.nameLower.includes(searchTerm.toLowerCase()) ||
|
||||
item.locationLower.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 (
|
||||
<>
|
||||
<Grid container>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={6}
|
||||
sx={{
|
||||
display:
|
||||
currentLocation.pathname ===
|
||||
baseRoute + 'list' ||
|
||||
currentLocation.pathname ===
|
||||
baseRoute + routes.list
|
||||
? 'block'
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<FormControl variant="outlined">
|
||||
<TextField
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchTwoToneIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
{isListView && (
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<FormControl variant="outlined" sx={{ width: 280 }}>
|
||||
<TextField
|
||||
placeholder={intl.formatMessage({ id: 'search' })}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchTwoToneIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<FlatInstallationView installations={filteredData} product={props.product} />
|
||||
<Routes>
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
// Helper to build form values from S3 data
|
||||
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||
minimumSoC: props.values.Config.MinSoc,
|
||||
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||
operatingPriority: resolveOperatingPriorityIndex(
|
||||
props.values.Config.OperatingPriority
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
vpnIp: '',
|
||||
installationModel: '',
|
||||
externalEms: 'No',
|
||||
dataCollectionEnabled: true,
|
||||
...(isSodistorePro ? { device: 4 } : {}),
|
||||
});
|
||||
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>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -275,6 +275,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
const isGrowatt = currentInstallation?.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
|
||||
? [
|
||||
{
|
||||
|
|
@ -471,6 +477,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
>
|
||||
{tabs
|
||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
|
||||
.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
|
|
@ -544,6 +551,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
>
|
||||
{singleInstallationTabs
|
||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
|
||||
.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,20 @@ import {
|
|||
CardContent,
|
||||
CardHeader,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Paper,
|
||||
Popper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
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 { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
|
@ -33,6 +38,7 @@ function CommentThread({
|
|||
onCommentAdded,
|
||||
adminUsers = []
|
||||
}: CommentThreadProps) {
|
||||
const intl = useIntl();
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -40,6 +46,68 @@ function CommentThread({
|
|||
const [uploading, setUploading] = useState(false);
|
||||
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 MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
|
|
@ -64,7 +132,15 @@ function CommentThread({
|
|||
try {
|
||||
let commentId: number | undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +166,8 @@ function CommentThread({
|
|||
}
|
||||
|
||||
setBody('');
|
||||
setMentionedIds([]);
|
||||
setMentionQuery(null);
|
||||
setSelectedFiles([]);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onCommentAdded();
|
||||
|
|
@ -166,10 +244,35 @@ function CommentThread({
|
|||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'mentionPlaceholder',
|
||||
defaultMessage: 'Type @ to mention a user'
|
||||
})}
|
||||
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' }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
Typography
|
||||
} from '@mui/material';
|
||||
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 {
|
||||
TicketPriority,
|
||||
|
|
@ -28,7 +28,8 @@ import {
|
|||
subCategoryLabels,
|
||||
subCategoriesByCategory,
|
||||
categoryLabels,
|
||||
otherSubCategoryValues
|
||||
otherSubCategoryValues,
|
||||
AdminUser
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
|
||||
type Installation = {
|
||||
|
|
@ -65,6 +66,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
|
||||
const intl = useIntl();
|
||||
const [subject, setSubject] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
||||
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
||||
|
|
@ -73,6 +75,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
useState<Installation | null>(null);
|
||||
const [loadingInstallations, setLoadingInstallations] = useState(false);
|
||||
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 [subCategory, setSubCategory] = useState<number>(
|
||||
TicketSubCategory.Battery
|
||||
|
|
@ -189,6 +193,16 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
.finally(() => setLoadingInstallations(false));
|
||||
}, [selectedProduct]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
axiosConfig
|
||||
.get('/GetAdminUsers')
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.data)) setAdminUsers(res.data);
|
||||
})
|
||||
.catch(() => setAdminUsers([]));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultInstallationId == null || !open) return;
|
||||
axiosConfig
|
||||
|
|
@ -233,6 +247,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
setAllInstallations([]);
|
||||
setSelectedInstallation(null);
|
||||
setPriority(TicketPriority.Medium);
|
||||
setAssigneeId('');
|
||||
setCategory(TicketCategory.Hardware);
|
||||
setSubCategory(TicketSubCategory.Battery);
|
||||
setDescription('');
|
||||
|
|
@ -244,6 +259,15 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
|
||||
const handleSubmit = async () => {
|
||||
if (!subject.trim()) return;
|
||||
if (assigneeId === '') {
|
||||
setError(
|
||||
intl.formatMessage({
|
||||
id: 'assigneeRequired',
|
||||
defaultMessage: 'Please assign this ticket to someone before creating it.'
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
|
|
@ -253,6 +277,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
description,
|
||||
installationId: selectedInstallation?.id ?? null,
|
||||
priority,
|
||||
assigneeId,
|
||||
category,
|
||||
subCategory: isOtherCategory ? 0 : subCategory,
|
||||
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">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ const statusCountKeys: {
|
|||
}[] = [
|
||||
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
||||
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
||||
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved', color: '#2e7d32' }
|
||||
];
|
||||
|
||||
function InstallationTicketsTab({ installationId }: Props) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const statusLabels: Record<number, string> = {
|
|||
[TicketStatus.Open]: 'Open',
|
||||
[TicketStatus.InProgress]: 'In Progress',
|
||||
[TicketStatus.Escalated]: 'Escalated',
|
||||
[TicketStatus.Resolved]: 'Resolved',
|
||||
[TicketStatus.Resolved]: 'Solved',
|
||||
[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 {
|
||||
Alert,
|
||||
|
|
@ -63,9 +63,7 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
|||
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
||||
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
||||
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
|
||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
|
||||
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
|
||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' }
|
||||
];
|
||||
|
||||
function TicketDetailPage() {
|
||||
|
|
@ -90,6 +88,9 @@ function TicketDetailPage() {
|
|||
const [savingDescription, setSavingDescription] = useState(false);
|
||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||
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
|
||||
const [editCustomSub, setEditCustomSub] = useState('');
|
||||
|
|
@ -153,9 +154,7 @@ function TicketDetailPage() {
|
|||
newStatus === TicketStatus.Resolved &&
|
||||
(!rootCause.trim() || !solution.trim())
|
||||
) {
|
||||
setResolutionError(
|
||||
'Root Cause and Solution are required to resolve a ticket.'
|
||||
);
|
||||
setSolveGateOpen(true);
|
||||
return;
|
||||
}
|
||||
setResolutionError('');
|
||||
|
|
@ -475,6 +474,7 @@ function TicketDetailPage() {
|
|||
error={
|
||||
!!resolutionError && !rootCause.trim()
|
||||
}
|
||||
inputRef={rootCauseRef}
|
||||
/>
|
||||
<TextField
|
||||
label={
|
||||
|
|
@ -491,6 +491,7 @@ function TicketDetailPage() {
|
|||
error={
|
||||
!!resolutionError && !solution.trim()
|
||||
}
|
||||
inputRef={solutionRef}
|
||||
/>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||
{resolutionSaved && (
|
||||
|
|
@ -591,11 +592,19 @@ function TicketDetailPage() {
|
|||
/>
|
||||
</em>
|
||||
</MenuItem>
|
||||
{adminUsers.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{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>
|
||||
|
||||
|
|
@ -869,6 +878,36 @@ function TicketDetailPage() {
|
|||
</DialogActions>
|
||||
</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>
|
||||
<Footer />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ import StatusChip from './StatusChip';
|
|||
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
||||
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
|
||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
|
||||
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
|
||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Solved' }
|
||||
};
|
||||
|
||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
|
|
|
|||
|
|
@ -42,21 +42,21 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
const inv = props.values?.InverterRecord;
|
||||
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
|
||||
? (inv?.TotalBatteryPower ?? 0)
|
||||
: Number(
|
||||
Array.from({ length: props.batteryClusterNumber }).reduce(
|
||||
(sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0),
|
||||
0
|
||||
)
|
||||
: growattActiveIndices.reduce(
|
||||
(sum, i) => sum + (Number(inv?.[`Battery${i}Power`]) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const pvPower: number = hasDevices
|
||||
? (inv?.TotalPhotovoltaicPower ?? 0)
|
||||
: (inv?.PvPower ??
|
||||
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
|
||||
.map((key) => inv?.[key] ?? 0)
|
||||
.reduce((sum, val) => sum + val, 0));
|
||||
: (inv?.PvPower ?? 0);
|
||||
|
||||
const totalLoadPower: number = hasDevices
|
||||
? (inv?.TotalLoadPower ?? 0)
|
||||
|
|
@ -65,6 +65,15 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
const totalGridPower: number =
|
||||
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 (
|
||||
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
||||
<Grid container>
|
||||
|
|
@ -255,42 +264,20 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
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
|
||||
key={index + 1}
|
||||
centerBox={{
|
||||
title: `Battery C${index + 1}`,
|
||||
data: inv
|
||||
? [
|
||||
{ value: soc, unit: '%' },
|
||||
{ value: power, unit: 'W' }
|
||||
]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
isFirst={false}
|
||||
isLast={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<TopologyColumn
|
||||
centerBox={{
|
||||
title: 'Battery',
|
||||
data: inv
|
||||
? [
|
||||
{ value: avgBatterySoc, unit: '%' },
|
||||
{ value: totalBatteryPower, unit: 'W' }
|
||||
]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
isFirst={false}
|
||||
isLast={true}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -257,14 +257,11 @@ function TopologyBox(props: TopologyBoxProps) {
|
|||
}}
|
||||
>
|
||||
{props.data.map((boxData, index) => {
|
||||
const formatted = formatPower(boxData.value, boxData.unit);
|
||||
return (
|
||||
<Typography key={index}>
|
||||
{formatPower(boxData.value, boxData.unit) === 0
|
||||
? null
|
||||
: formatPower(boxData.value, boxData.unit)}
|
||||
{formatPower(boxData.value, boxData.unit) === 0
|
||||
? null
|
||||
: boxData.unit}
|
||||
{formatted === 0 ? '0 ' : formatted}
|
||||
{boxData.unit}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -160,50 +160,64 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
|
||||
{props.node.type === 'Installation' && (
|
||||
<div>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
{(props.node as any).dataCollectionEnabled === false ? (
|
||||
<div
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '30px',
|
||||
marginTop: '30px'
|
||||
marginLeft: '17px',
|
||||
backgroundColor: 'grey'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '30px',
|
||||
marginTop: '30px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d',
|
||||
marginLeft: '22px',
|
||||
marginTop: '30px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d',
|
||||
marginLeft: '22px',
|
||||
marginTop: '30px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '17px',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '17px',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,12 +17,16 @@ function InstallationTree() {
|
|||
useContext(InstallationsContext);
|
||||
|
||||
const sortedInstallations = [...foldersAndInstallations].sort((a, b) => {
|
||||
// Compare the status field of each installation and sort them based on the status.
|
||||
//Installations with alarms go first
|
||||
|
||||
// Folders stay on top (existing behavior).
|
||||
if (a.type == 'Folder') {
|
||||
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 b_status = b.status;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface I_Installation extends I_S3Credentials {
|
|||
product: number;
|
||||
device: number;
|
||||
testingMode?: boolean;
|
||||
dataCollectionEnabled?: boolean;
|
||||
status?: number;
|
||||
serialNumber?: string;
|
||||
networkProvider: string;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@
|
|||
"externalEmsOther": "Externes EMS (angeben)",
|
||||
"emsNo": "Nein",
|
||||
"emsOther": "Andere",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"dataCollectionEnabled": "Datenerfassung",
|
||||
"generalInfo": "Allgemeine Informationen",
|
||||
"installationSetup": "Installationseinrichtung",
|
||||
"couplingType": "AC/DC-Kopplung",
|
||||
|
|
@ -574,6 +577,7 @@
|
|||
"resolvedAt": "Gelöst",
|
||||
"noDescription": "Keine Beschreibung vorhanden.",
|
||||
"assignee": "Zuständig",
|
||||
"assigneeRequired": "Bitte weisen Sie dieses Ticket jemandem zu, bevor Sie es erstellen.",
|
||||
"unassigned": "Nicht zugewiesen",
|
||||
"deleteTicket": "Löschen",
|
||||
"confirmDeleteTicket": "Ticket löschen?",
|
||||
|
|
@ -594,6 +598,11 @@
|
|||
"statusEscalated": "Eskaliert",
|
||||
"statusResolved": "Gelöst",
|
||||
"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",
|
||||
"priorityHigh": "Hoch",
|
||||
"priorityMedium": "Mittel",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@
|
|||
"externalEmsOther": "External EMS (specify)",
|
||||
"emsNo": "No",
|
||||
"emsOther": "Other",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"dataCollectionEnabled": "Data Collection",
|
||||
"generalInfo": "General Info",
|
||||
"installationSetup": "Installation Setup",
|
||||
"couplingType": "AC/DC Coupling",
|
||||
|
|
@ -322,6 +325,7 @@
|
|||
"resolvedAt": "Resolved",
|
||||
"noDescription": "No description provided.",
|
||||
"assignee": "Assignee",
|
||||
"assigneeRequired": "Please assign this ticket to someone before creating it.",
|
||||
"unassigned": "Unassigned",
|
||||
"deleteTicket": "Delete",
|
||||
"confirmDeleteTicket": "Delete Ticket?",
|
||||
|
|
@ -340,8 +344,13 @@
|
|||
"statusOpen": "Open",
|
||||
"statusInProgress": "In Progress",
|
||||
"statusEscalated": "Escalated",
|
||||
"statusResolved": "Resolved",
|
||||
"statusResolved": "Solved",
|
||||
"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",
|
||||
"priorityHigh": "High",
|
||||
"priorityMedium": "Medium",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@
|
|||
"externalEmsOther": "EMS externe (préciser)",
|
||||
"emsNo": "Non",
|
||||
"emsOther": "Autre",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"dataCollectionEnabled": "Collecte de données",
|
||||
"generalInfo": "Informations générales",
|
||||
"installationSetup": "Configuration de l'installation",
|
||||
"couplingType": "Couplage AC/DC",
|
||||
|
|
@ -574,6 +577,7 @@
|
|||
"resolvedAt": "Résolu",
|
||||
"noDescription": "Aucune description fournie.",
|
||||
"assignee": "Responsable",
|
||||
"assigneeRequired": "Veuillez assigner ce ticket à quelqu'un avant de le créer.",
|
||||
"unassigned": "Non assigné",
|
||||
"deleteTicket": "Supprimer",
|
||||
"confirmDeleteTicket": "Supprimer le ticket ?",
|
||||
|
|
@ -594,6 +598,11 @@
|
|||
"statusEscalated": "Escaladé",
|
||||
"statusResolved": "Résolu",
|
||||
"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",
|
||||
"priorityHigh": "Élevée",
|
||||
"priorityMedium": "Moyenne",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@
|
|||
"externalEmsOther": "EMS esterno (specificare)",
|
||||
"emsNo": "No",
|
||||
"emsOther": "Altro",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"dataCollectionEnabled": "Raccolta dati",
|
||||
"generalInfo": "Informazioni generali",
|
||||
"installationSetup": "Configurazione installazione",
|
||||
"couplingType": "Accoppiamento AC/DC",
|
||||
|
|
@ -574,6 +577,7 @@
|
|||
"resolvedAt": "Risolto",
|
||||
"noDescription": "Nessuna descrizione fornita.",
|
||||
"assignee": "Assegnatario",
|
||||
"assigneeRequired": "Assegna questo ticket a qualcuno prima di crearlo.",
|
||||
"unassigned": "Non assegnato",
|
||||
"deleteTicket": "Elimina",
|
||||
"confirmDeleteTicket": "Eliminare il ticket?",
|
||||
|
|
@ -594,6 +598,11 @@
|
|||
"statusEscalated": "Escalato",
|
||||
"statusResolved": "Risolto",
|
||||
"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",
|
||||
"priorityHigh": "Alta",
|
||||
"priorityMedium": "Media",
|
||||
|
|
|
|||
Loading…
Reference in New Issue