Compare commits

...

8 Commits

37 changed files with 1000 additions and 308 deletions

View File

@ -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;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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>();

View File

@ -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}");

View File

@ -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;

View File

@ -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;

View File

@ -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' }}>

View File

@ -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]],
},
};

View File

@ -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;

View File

@ -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}

View File

@ -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

View File

@ -48,6 +48,11 @@ export const getChartOptions = (
curve: 'smooth',
width: 2
},
grid: {
padding: {
top: 30
}
},
yaxis:
type === 'dailyoverview'
? [

View File

@ -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={{

View File

@ -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>

View File

@ -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>

View File

@ -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
),

View File

@ -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={{

View File

@ -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}

View File

@ -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}

View File

@ -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" />

View File

@ -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) {

View File

@ -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'
};

View File

@ -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>

View File

@ -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 }> = {

View File

@ -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>
</>
)}

View File

@ -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>
);
})}

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",