Compare commits

..

No commits in common. "4a6caa9ed31072162407f98370178cd146dc88b0" and "18d47232b739d2995f1ae6bb5aaa8b13fca0e227" have entirely different histories.

37 changed files with 311 additions and 1003 deletions

View File

@ -2176,17 +2176,6 @@ public class Controller : ControllerBase
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
if (ticket.AssigneeId.HasValue && ticket.AssigneeId.Value != user.Id)
{
var assignee = Db.GetUserById(ticket.AssigneeId);
if (assignee is not null)
_ = Task.Run(async () =>
{
try { await assignee.SendTicketAssignedEmail(ticket); }
catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email on create: {ex}"); }
});
}
// Fire-and-forget AI diagnosis // Fire-and-forget AI diagnosis
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning(); TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
@ -2232,40 +2221,6 @@ public class Controller : ControllerBase
ActorId = user.Id, ActorId = user.Id,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
var isSolveTransition = ticket.Status == (Int32)TicketStatus.Resolved
&& existing.Status != (Int32)TicketStatus.Resolved;
var isReopenTransition = existing.Status == (Int32)TicketStatus.Resolved
&& (ticket.Status == (Int32)TicketStatus.InProgress
|| ticket.Status == (Int32)TicketStatus.Open);
if (isSolveTransition)
{
var creator = Db.GetUserById(existing.CreatedByUserId);
if (creator is not null && creator.Id != user.Id)
{
var actorName = user.Name;
_ = Task.Run(async () =>
{
try { await creator.SendTicketSolvedEmail(ticket, actorName); }
catch (Exception ex) { Console.WriteLine($"Failed to send ticket solved email: {ex}"); }
});
}
}
if (isReopenTransition && existing.AssigneeId.HasValue)
{
var assignee = Db.GetUserById(existing.AssigneeId);
if (assignee is not null && assignee.Id != user.Id)
{
var actorName = user.Name;
_ = Task.Run(async () =>
{
try { await assignee.SendTicketReopenedEmail(ticket, actorName); }
catch (Exception ex) { Console.WriteLine($"Failed to send ticket reopened email: {ex}"); }
});
}
}
} }
if (resolutionAdded) if (resolutionAdded)
@ -2298,7 +2253,7 @@ public class Controller : ControllerBase
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
if (assignee is not null && assignee.Id != user.Id) if (assignee is not null)
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try { await assignee.SendTicketAssignedEmail(ticket); } try { await assignee.SendTicketAssignedEmail(ticket); }
@ -2366,35 +2321,6 @@ public class Controller : ControllerBase
ticket.UpdatedAt = DateTime.UtcNow; ticket.UpdatedAt = DateTime.UtcNow;
Db.Update(ticket); Db.Update(ticket);
var mentioned = (comment.MentionedUserIds ?? new List<Int64>())
.Distinct()
.Where(uid => uid != user.Id)
.ToList();
foreach (var uid in mentioned)
{
Db.Create(new TicketCommentMention
{
CommentId = comment.Id,
MentionedUserId = uid,
CreatedAt = DateTime.UtcNow
});
var mentionedUser = Db.GetUserById(uid);
if (mentionedUser is null) continue;
var actorName = user.Name;
var body = comment.Body ?? "";
var excerpt = body.Length > 300 ? body.Substring(0, 300) + "..." : body;
var ticketRef = ticket;
_ = Task.Run(async () =>
{
try { await mentionedUser.SendTicketMentionEmail(ticketRef, actorName, excerpt); }
catch (Exception ex) { Console.WriteLine($"Failed to send mention email: {ex}"); }
});
}
return comment; return comment;
} }

View File

@ -48,7 +48,6 @@ public class Installation : TreeNode
public String ReadRoleId { get; set; } = ""; public String ReadRoleId { get; set; } = "";
public String WriteRoleId { get; set; } = ""; public String WriteRoleId { get; set; } = "";
public Boolean TestingMode { get; set; } = false; public Boolean TestingMode { get; set; } = false;
public Boolean DataCollectionEnabled { get; set; } = true;
public int Status { get; set; } = -1; public int Status { get; set; } = -1;
public int Product { get; set; } = (int)ProductType.Salimax; public int Product { get; set; } = (int)ProductType.Salimax;
public int Device { get; set; } = 0; public int Device { get; set; } = 0;

View File

@ -359,163 +359,4 @@ public static class UserMethods
return user.SendEmail(subject, body); return user.SendEmail(subject, body);
} }
public static Task SendTicketSolvedEmail(this User user, Ticket ticket, String solvedByName)
{
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
$"inesco energy Ticket #{ticket.Id} wurde gelöst",
$"Sehr geehrte/r {user.Name},\n\n" +
$"Ihr Ticket wurde als gelöst markiert von {solvedByName}:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Betreff: {ticket.Subject}\n\n" +
$"Ursache:\n{ticket.RootCause}\n\n" +
$"Lösung:\n{ticket.Solution}\n\n" +
$"Falls das Problem weiterhin besteht, öffnen Sie das Ticket erneut: {ticketLink}\n\n" +
"Mit freundlichen Grüssen\ninesco energy Monitor"
),
"fr" => (
$"inesco energy Le ticket #{ticket.Id} a été résolu",
$"Cher/Chère {user.Name},\n\n" +
$"Votre ticket a été marqué comme résolu par {solvedByName} :\n\n" +
$"Ticket : #{ticket.Id}\n" +
$"Objet : {ticket.Subject}\n\n" +
$"Cause :\n{ticket.RootCause}\n\n" +
$"Solution :\n{ticket.Solution}\n\n" +
$"Si le problème persiste, rouvrez le ticket : {ticketLink}\n\n" +
"Cordialement,\ninesco energy Monitor"
),
"it" => (
$"inesco energy Il ticket #{ticket.Id} è stato risolto",
$"Gentile {user.Name},\n\n" +
$"Il suo ticket è stato contrassegnato come risolto da {solvedByName}:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Oggetto: {ticket.Subject}\n\n" +
$"Causa:\n{ticket.RootCause}\n\n" +
$"Soluzione:\n{ticket.Solution}\n\n" +
$"Se il problema persiste, riaprire il ticket: {ticketLink}\n\n" +
"Cordiali saluti,\ninesco energy Monitor"
),
_ => (
$"inesco energy Ticket #{ticket.Id} has been solved",
$"Dear {user.Name},\n\n" +
$"Your ticket has been marked as solved by {solvedByName}:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Subject: {ticket.Subject}\n\n" +
$"Root cause:\n{ticket.RootCause}\n\n" +
$"Solution:\n{ticket.Solution}\n\n" +
$"If the issue persists, reopen the ticket: {ticketLink}\n\n" +
"Best regards,\ninesco energy Monitor"
)
};
return user.SendEmail(subject, body);
}
public static Task SendTicketReopenedEmail(this User user, Ticket ticket, String reopenedByName)
{
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
var priority = (TicketPriority)ticket.Priority;
var category = (TicketCategory)ticket.Category;
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
$"inesco energy Ticket #{ticket.Id} wurde wieder geöffnet",
$"Sehr geehrte/r {user.Name},\n\n" +
$"Ein Ihnen zugewiesenes Ticket wurde von {reopenedByName} wieder geöffnet:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Betreff: {ticket.Subject}\n" +
$"Priorität: {priority}\n" +
$"Kategorie: {category}\n\n" +
$"Öffnen Sie das Ticket hier: {ticketLink}\n\n" +
"Mit freundlichen Grüssen\ninesco energy Monitor"
),
"fr" => (
$"inesco energy Le ticket #{ticket.Id} a été rouvert",
$"Cher/Chère {user.Name},\n\n" +
$"Un ticket qui vous est attribué a été rouvert par {reopenedByName} :\n\n" +
$"Ticket : #{ticket.Id}\n" +
$"Objet : {ticket.Subject}\n" +
$"Priorité : {priority}\n" +
$"Catégorie : {category}\n\n" +
$"Ouvrir le ticket : {ticketLink}\n\n" +
"Cordialement,\ninesco energy Monitor"
),
"it" => (
$"inesco energy Il ticket #{ticket.Id} è stato riaperto",
$"Gentile {user.Name},\n\n" +
$"Un ticket assegnato a lei è stato riaperto da {reopenedByName}:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Oggetto: {ticket.Subject}\n" +
$"Priorità: {priority}\n" +
$"Categoria: {category}\n\n" +
$"Aprire il ticket: {ticketLink}\n\n" +
"Cordiali saluti,\ninesco energy Monitor"
),
_ => (
$"inesco energy Ticket #{ticket.Id} has been reopened",
$"Dear {user.Name},\n\n" +
$"A ticket assigned to you has been reopened by {reopenedByName}:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Subject: {ticket.Subject}\n" +
$"Priority: {priority}\n" +
$"Category: {category}\n\n" +
$"Open the ticket: {ticketLink}\n\n" +
"Best regards,\ninesco energy Monitor"
)
};
return user.SendEmail(subject, body);
}
public static Task SendTicketMentionEmail(this User user, Ticket ticket, String mentionedByName, String commentExcerpt)
{
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
$"inesco energy Sie wurden in Ticket #{ticket.Id} erwähnt",
$"Sehr geehrte/r {user.Name},\n\n" +
$"{mentionedByName} hat Sie in einem Kommentar zu Ticket #{ticket.Id} erwähnt:\n\n" +
$"Betreff: {ticket.Subject}\n\n" +
$"Kommentar:\n\"{commentExcerpt}\"\n\n" +
$"Ticket öffnen: {ticketLink}\n\n" +
"Mit freundlichen Grüssen\ninesco energy Monitor"
),
"fr" => (
$"inesco energy Vous avez été mentionné dans le ticket #{ticket.Id}",
$"Cher/Chère {user.Name},\n\n" +
$"{mentionedByName} vous a mentionné dans un commentaire sur le ticket #{ticket.Id} :\n\n" +
$"Objet : {ticket.Subject}\n\n" +
$"Commentaire :\n« {commentExcerpt} »\n\n" +
$"Ouvrir le ticket : {ticketLink}\n\n" +
"Cordialement,\ninesco energy Monitor"
),
"it" => (
$"inesco energy È stato menzionato nel ticket #{ticket.Id}",
$"Gentile {user.Name},\n\n" +
$"{mentionedByName} l'ha menzionata in un commento sul ticket #{ticket.Id}:\n\n" +
$"Oggetto: {ticket.Subject}\n\n" +
$"Commento:\n\"{commentExcerpt}\"\n\n" +
$"Aprire il ticket: {ticketLink}\n\n" +
"Cordiali saluti,\ninesco energy Monitor"
),
_ => (
$"inesco energy You were mentioned in ticket #{ticket.Id}",
$"Dear {user.Name},\n\n" +
$"{mentionedByName} mentioned you in a comment on ticket #{ticket.Id}:\n\n" +
$"Subject: {ticket.Subject}\n\n" +
$"Comment:\n\"{commentExcerpt}\"\n\n" +
$"Open the ticket: {ticketLink}\n\n" +
"Best regards,\ninesco energy Monitor"
)
};
return user.SendEmail(subject, body);
}
} }

View File

@ -13,6 +13,4 @@ public class TicketComment
public Int64? AuthorId { get; set; } public Int64? AuthorId { get; set; }
public String Body { get; set; } = ""; public String Body { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Ignore] public List<Int64> MentionedUserIds { get; set; } = new();
} }

View File

@ -1,12 +0,0 @@
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,7 +87,6 @@ public static partial class Db
public static Boolean Create(TicketComment comment) => Insert(comment); public static Boolean Create(TicketComment comment) => Insert(comment);
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
public static Boolean Create(TicketCommentMention mention) => Insert(mention);
// Document storage // Document storage
public static Boolean Create(Document document) => Insert(document); public static Boolean Create(Document document) => Insert(document);

View File

@ -91,11 +91,6 @@ public static partial class Db
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
// Backfill: SQLite-net adds new bool columns as nullable with NULL for existing rows.
// LINQ `.Where(i => i.DataCollectionEnabled)` translates to `WHERE ... = 1` and excludes
// NULL rows, which would silently disable ingestion for every pre-existing installation.
Connection.Execute("UPDATE Installation SET DataCollectionEnabled = 1 WHERE DataCollectionEnabled IS NULL");
// One-time migration: rebrand to inesco energy // One-time migration: rebrand to inesco energy
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'"); Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'"); Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
@ -143,7 +138,6 @@ public static partial class Db
// Ticket system tables // Ticket system tables
fileConnection.CreateTable<Ticket>(); fileConnection.CreateTable<Ticket>();
fileConnection.CreateTable<TicketComment>(); fileConnection.CreateTable<TicketComment>();
fileConnection.CreateTable<TicketCommentMention>();
fileConnection.CreateTable<TicketAiDiagnosis>(); fileConnection.CreateTable<TicketAiDiagnosis>();
fileConnection.CreateTable<TicketTimelineEvent>(); fileConnection.CreateTable<TicketTimelineEvent>();

View File

@ -39,7 +39,7 @@ public static class DeleteOldDataFromS3
{ {
var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds(); var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds();
var cutoffKey = cutoffTimestamp.ToString(); var cutoffKey = cutoffTimestamp.ToString();
var installations = Db.Installations.Where(i => i.DataCollectionEnabled).ToList(); var installations = Db.Installations.ToList();
Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}"); 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..."); Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
var installations = Db.Installations var installations = Db.Installations
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3 && i.DataCollectionEnabled) // Skip Growatt (device=3) and installations with data collection disabled .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
.ToList(); .ToList();
foreach (var installation in installations) foreach (var installation in installations)
@ -75,13 +75,6 @@ public static class DailyIngestionService
/// </summary> /// </summary>
public static async Task IngestInstallationAsync(Int64 installationId) public static async Task IngestInstallationAsync(Int64 installationId)
{ {
var installation = Db.GetInstallationById(installationId);
if (installation is null || !installation.DataCollectionEnabled)
{
Console.WriteLine($"[DailyIngestion] Skipping installation {installationId} (data collection disabled).");
return;
}
await TryIngestFromJson(installationId); await TryIngestFromJson(installationId);
IngestFromXlsx(installationId); IngestFromXlsx(installationId);
} }
@ -95,11 +88,6 @@ public static class DailyIngestionService
{ {
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
if (installation is null) return; if (installation is null) return;
if (!installation.DataCollectionEnabled)
{
Console.WriteLine($"[DailyIngestion] Skipping date-range ingest for installation {installationId} (data collection disabled).");
return;
}
var newDaily = 0; var newDaily = 0;
var newHourly = 0; var newHourly = 0;

View File

@ -106,7 +106,7 @@ public static class ReportAggregationService
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation..."); Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
var installations = Db.Installations var installations = Db.Installations
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3 && i.DataCollectionEnabled) // Skip Growatt (device=3) and installations with data collection disabled .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
.ToList(); .ToList();
var generated = 0; var generated = 0;

View File

@ -762,28 +762,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
/> />
</div> </div>
<div>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage id="dataCollectionEnabled" defaultMessage="Data Collection" />
</InputLabel>
<Select
name="dataCollectionEnabled"
value={formValues.dataCollectionEnabled === false ? 'no' : 'yes'}
onChange={(e) =>
setFormValues({
...formValues,
dataCollectionEnabled: e.target.value === 'yes'
})
}
inputProps={{ readOnly: !canEdit }}
>
<MenuItem value="yes"><FormattedMessage id="yes" defaultMessage="Yes" /></MenuItem>
<MenuItem value="no"><FormattedMessage id="no" defaultMessage="No" /></MenuItem>
</Select>
</FormControl>
</div>
<div> <div>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}> <FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}> <InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>

View File

@ -20,11 +20,10 @@ export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>>
'sodistore home 18': [[4]], 'sodistore home 18': [[4]],
}, },
4: { 4: {
'sodistore home 9': [[1, 1]], 'sodistore home 9': [[1, 1]],
'sodistore home 13.5': [[2, 1]], 'sodistore home 18': [[2, 2]],
'sodistore home 18': [[2, 2]], 'sodistore home 27': [[2, 2], [1, 1]],
'sodistore home 27': [[2, 2], [1, 1]], 'sodistore home 36': [[2, 2], [2, 2]],
'sodistore home 36': [[2, 2], [2, 2]],
}, },
}; };

View File

@ -96,12 +96,8 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
break; break;
} }
// Sort by status (alarms first); data-collection-disabled sinks below offline. // Sort by status (alarms first)
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
const aDisabled = a.dataCollectionEnabled === false;
const bDisabled = b.dataCollectionEnabled === false;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
const a_status = a.status; const a_status = a.status;
const b_status = b.status; const b_status = b.status;

View File

@ -25,7 +25,6 @@ import Information from '../Information/Information';
import { UserType } from '../../../interfaces/UserTypes'; import { UserType } from '../../../interfaces/UserTypes';
import HistoryOfActions from '../History/History'; import HistoryOfActions from '../History/History';
import Topology from '../Topology/Topology'; import Topology from '../Topology/Topology';
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import BatteryView from '../BatteryView/BatteryView'; import BatteryView from '../BatteryView/BatteryView';
import Configuration from '../Configuration/Configuration'; import Configuration from '../Configuration/Configuration';
import PvView from '../PvView/PvView'; import PvView from '../PvView/PvView';
@ -466,12 +465,20 @@ function Installation(props: singleInstallationProps) {
path={routes.live} path={routes.live}
element={ element={
props.current_installation.product === 4 ? ( props.current_installation.product === 4 ? (
<TopologySodistoreHome // TODO: SodistoreGrid — implement actual topology layout
values={values} <Container
connected={connected} maxWidth="xl"
loading={loading} sx={{
batteryClusterNumber={props.current_installation.batteryClusterNumber} display: 'flex',
></TopologySodistoreHome> justifyContent: 'center',
alignItems: 'center',
height: '40vh'
}}
>
<Typography variant="body1" color="text.secondary">
Live view coming soon
</Typography>
</Container>
) : ( ) : (
<Topology <Topology
values={values} values={values}

View File

@ -1140,11 +1140,7 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
'InverterRecord.PvPower', 'InverterRecord.PvPower',
'InverterRecord.Battery1Power', 'InverterRecord.Battery1Power',
'InverterRecord.Battery2Power', 'InverterRecord.Battery2Power',
'InverterRecord.ConsumptionPower', 'InverterRecord.ConsumptionPower'
'InverterRecord.TotalBatteryPower',
'InverterRecord.TotalPhotovoltaicPower',
'InverterRecord.TotalLoadPower',
'InverterRecord.TotalGridPower'
]; ];
// Helper function to safely get a value from a nested path // Helper function to safely get a value from a nested path

View File

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

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'; import React, { useState } from 'react';
import { import {
Card, Card,
CircularProgress, CircularProgress,
@ -31,40 +31,34 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1); const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation(); const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations; const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
//
const sortedInstallations = [...props.installations].sort((a, b) => {
// Compare the status field of each installation and sort them based on the status.
//Installations with alarms go first
let a_status = a.status;
let b_status = b.status;
const sortedInstallations = useMemo(() => { if (a_status > b_status) {
return [...props.installations].sort((a, b) => { return -1;
// Data-collection-disabled installations sink below everything (even offline). }
const aDisabled = a.dataCollectionEnabled === false; if (a_status < b_status) {
const bDisabled = b.dataCollectionEnabled === false; return 1;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1; }
return 0;
// 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 => { const handleSelectOneInstallation = (installationID: number): void => {
if (selectedInstallation != installationID) { if (selectedInstallation != installationID) {
setSelectedInstallation(installationID); setSelectedInstallation(installationID);
setSelectedInstallation(-1); setSelectedInstallation(-1);
const target = props.installations.find((i) => i.id === installationID);
const landingTab =
target?.dataCollectionEnabled === false ? routes.information : routes.live;
navigate( navigate(
baseRoute + baseRoute +
routes.list + routes.list +
routes.installation + routes.installation +
`${installationID}` + `${installationID}` +
'/' + '/' +
landingTab, routes.live,
{ {
replace: true replace: true
} }
@ -83,16 +77,18 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
} }
})); }));
const isListView =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
return ( return (
<Grid container spacing={1} sx={{ marginTop: 0.1 }}> <Grid container spacing={1} sx={{ marginTop: 0.1 }}>
<Grid <Grid
item item
sx={{ sx={{
display: isListView ? 'block' : 'none' display:
currentLocation.pathname ===
baseRoute + 'list' ||
currentLocation.pathname ===
baseRoute + routes.list
? 'block'
: 'none'
}} }}
> >
<Card> <Card>
@ -213,60 +209,46 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
marginLeft: '15px' marginLeft: '15px'
}} }}
> >
{installation.dataCollectionEnabled === false ? ( {status === -1 ? (
<div <CancelIcon
style={{ style={{
width: '20px', width: '23px',
height: '20px', height: '23px',
marginLeft: '2px', color: 'red',
borderRadius: '50%', borderRadius: '50%'
backgroundColor: 'grey'
}} }}
/> />
) : ( ) : (
<> ''
{status === -1 ? (
<CancelIcon
style={{
width: '23px',
height: '23px',
color: 'red',
borderRadius: '50%'
}}
/>
) : (
''
)}
{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'
}}
/>
</>
)} )}
{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'
}}
/>
{installation.testingMode && ( {installation.testingMode && (
<BuildIcon <BuildIcon
style={{ style={{

View File

@ -62,7 +62,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
const [values, setValues] = useState<JSONRecordData | null>(null); const [values, setValues] = useState<JSONRecordData | null>(null);
const status = props.current_installation.status; const status = props.current_installation.status;
const dataCollectionDisabled = props.current_installation.dataCollectionEnabled === false;
const [ const [
failedToCommunicateWithInstallation, failedToCommunicateWithInstallation,
setFailedToCommunicateWithInstallation setFailedToCommunicateWithInstallation
@ -418,61 +417,47 @@ function SodioHomeInstallation(props: singleInstallationProps) {
marginTop: '-10px' marginTop: '-10px'
}} }}
> >
{dataCollectionDisabled ? ( {status === -1 ? (
<div <CancelIcon
style={{ style={{
width: '20px', width: '23px',
height: '20px', height: '23px',
marginLeft: '2px', color: 'red',
borderRadius: '50%', borderRadius: '50%'
backgroundColor: 'grey'
}} }}
/> />
) : ( ) : (
<> ''
{status === -1 ? (
<CancelIcon
style={{
width: '23px',
height: '23px',
color: 'red',
borderRadius: '50%'
}}
/>
) : (
''
)}
{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'
}}
/>
</>
)} )}
{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'
}}
/>
{props.current_installation.testingMode && ( {props.current_installation.testingMode && (
<BuildIcon <BuildIcon
style={{ style={{
@ -489,14 +474,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</div> </div>
</div> </div>
{loading && {loading &&
!dataCollectionDisabled &&
currentTab != 'information' && currentTab != 'information' &&
// currentTab != 'manage' && // currentTab != 'manage' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'log' && currentTab != 'log' &&
currentTab != 'report' && currentTab != 'report' &&
currentTab != 'installationTickets' && currentTab != 'installationTickets' && (
currentTab != 'documents' && (
<Container <Container
maxWidth="xl" maxWidth="xl"
sx={{ sx={{
@ -538,7 +521,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
} }
/> />
{currentUser.userType !== UserType.client && !dataCollectionDisabled && ( {currentUser.userType !== UserType.client && (
<Route <Route
path={routes.log} path={routes.log}
element={ element={
@ -551,21 +534,19 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
)} )}
{!dataCollectionDisabled && ( <Route
<Route path={routes.live}
path={routes.live} element={
element={ <TopologySodistoreHome
<TopologySodistoreHome values={values}
values={values} connected={connected}
connected={connected} loading={loading}
loading={loading} batteryClusterNumber={props.current_installation.batteryClusterNumber}
batteryClusterNumber={props.current_installation.batteryClusterNumber} ></TopologySodistoreHome>
></TopologySodistoreHome> }
} />
/>
)}
{currentUser.userType !== UserType.client && !dataCollectionDisabled && ( {currentUser.userType !== UserType.client && (
<Route <Route
path={routes.batteryview + '/*'} path={routes.batteryview + '/*'}
element={ element={
@ -592,7 +573,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
)} )}
{currentUser.userType == UserType.admin && !dataCollectionDisabled && ( {currentUser.userType == UserType.admin && (
<Route <Route
path={routes.configuration} path={routes.configuration}
element={ element={
@ -619,23 +600,21 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
)} */} )} */}
{!dataCollectionDisabled && ( <Route
<Route path={routes.overview}
path={routes.overview} element={
element={ <Overview
<Overview s3Credentials={s3Credentials}
s3Credentials={s3Credentials} id={props.current_installation.id}
id={props.current_installation.id} device={props.current_installation.device}
device={props.current_installation.device} product={props.current_installation.product}
product={props.current_installation.product} connected={connected}
connected={connected} loading={loading}
loading={loading} />
/> }
} />
/>
)}
{props.current_installation.device !== 3 && !dataCollectionDisabled && ( {props.current_installation.device !== 3 && (
<Route <Route
path={routes.report} path={routes.report}
element={ element={
@ -672,7 +651,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>} element={<Navigate to={routes.live}></Navigate>}
/> />
</Routes> </Routes>
</Grid> </Grid>

View File

@ -1,15 +1,7 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
FormControl,
Grid,
InputAdornment,
InputLabel,
MenuItem,
Select,
TextField
} from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone'; import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import { FormattedMessage, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { I_Installation } from '../../../interfaces/InstallationTypes'; import { I_Installation } from '../../../interfaces/InstallationTypes';
import { Route, Routes, useLocation } from 'react-router-dom'; import { Route, Routes, useLocation } from 'react-router-dom';
import routes from '../../../Resources/routes.json'; import routes from '../../../Resources/routes.json';
@ -24,10 +16,9 @@ interface installationSearchProps {
function InstallationSearch(props: installationSearchProps) { function InstallationSearch(props: installationSearchProps) {
const intl = useIntl(); const intl = useIntl();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortByStatus, setSortByStatus] = useState('All Installations');
const [sortByAction, setSortByAction] = useState('All Installations');
const currentLocation = useLocation(); const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations; const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
// const [filteredData, setFilteredData] = useState(props.installations);
const indexedData = useMemo(() => { const indexedData = useMemo(() => {
return props.installations.map((item) => ({ return props.installations.map((item) => ({
@ -39,126 +30,56 @@ function InstallationSearch(props: installationSearchProps) {
}, [props.installations]); }, [props.installations]);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
let list = indexedData.filter( return indexedData.filter(
(item) => (item) =>
item.nameLower.includes(searchTerm.toLowerCase()) || item.nameLower.includes(searchTerm.toLowerCase()) ||
item.locationLower.includes(searchTerm.toLowerCase()) || item.locationLower.includes(searchTerm.toLowerCase()) ||
item.regionLower.includes(searchTerm.toLowerCase()) item.regionLower.includes(searchTerm.toLowerCase())
); );
}, [searchTerm, indexedData]);
switch (sortByStatus) {
case 'Installations With Alarm':
list = list.filter((i) => i.status === 2);
break;
case 'Installations with Warning':
list = list.filter((i) => i.status === 1);
break;
case 'Functional Installations':
list = list.filter((i) => i.status === 0);
break;
case 'Offline Installations':
list = list.filter((i) => i.status === -1);
break;
case 'Installations Without Data Collection':
list = list.filter((i) => i.dataCollectionEnabled === false);
break;
}
switch (sortByAction) {
case 'Installations With Action Flag':
list = list.filter((i) => i.testingMode === true);
break;
case 'Installations Without Action Flag':
list = list.filter((i) => i.testingMode === false);
break;
}
return list;
}, [searchTerm, indexedData, sortByStatus, sortByAction]);
const isListView =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
return ( return (
<> <>
{isListView && ( <Grid container>
<Grid container> <Grid
<Grid item xs={12}> item
<div xs={12}
style={{ md={6}
display: 'flex', sx={{
flexDirection: 'row', display:
alignItems: 'center', currentLocation.pathname ===
gap: '16px', baseRoute + 'list' ||
width: '100%', currentLocation.pathname ===
flexWrap: 'wrap' baseRoute + routes.list
}} ? 'block'
> : 'none'
<FormControl variant="outlined" sx={{ width: 280 }}> }}
<TextField >
placeholder={intl.formatMessage({ id: 'search' })} <div
value={searchTerm} style={{
onChange={(e) => setSearchTerm(e.target.value)} display: 'flex',
fullWidth flexDirection: 'column',
InputProps={{ alignItems: 'flex-start'
startAdornment: ( }}
<InputAdornment position="start"> >
<SearchTwoToneIcon /> <FormControl variant="outlined">
</InputAdornment> <TextField
) placeholder={intl.formatMessage({ id: 'search' })}
}} value={searchTerm}
/> onChange={(e) => setSearchTerm(e.target.value)}
</FormControl> fullWidth
InputProps={{
<FormControl sx={{ width: 280 }}> startAdornment: (
<InputLabel> <InputAdornment position="start">
<FormattedMessage id="sortByStatus" defaultMessage="Sort By Status" /> <SearchTwoToneIcon />
</InputLabel> </InputAdornment>
<Select )
value={sortByStatus} }}
onChange={(e) => setSortByStatus(e.target.value)} />
label={intl.formatMessage({ id: 'sortByStatus' })} </FormControl>
> </div>
{[
'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>
)} </Grid>
<FlatInstallationView installations={filteredData} product={props.product} /> <FlatInstallationView installations={filteredData} product={props.product} />
<Routes> <Routes>

View File

@ -91,8 +91,8 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
// Helper to build form values from S3 data // Helper to build form values from S3 data
const getS3Values = (): Partial<ConfigurationValues> => ({ const getS3Values = (): Partial<ConfigurationValues> => ({
minimumSoC: props.values.Config.MinSoc, minimumSoC: props.values.Config.MinSoc,
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent, maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent, maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
operatingPriority: resolveOperatingPriorityIndex( operatingPriority: resolveOperatingPriorityIndex(
props.values.Config.OperatingPriority props.values.Config.OperatingPriority
), ),

View File

@ -35,7 +35,6 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
vpnIp: '', vpnIp: '',
installationModel: '', installationModel: '',
externalEms: 'No', externalEms: 'No',
dataCollectionEnabled: true,
...(isSodistorePro ? { device: 4 } : {}), ...(isSodistorePro ? { device: 4 } : {}),
}); });
const [inverterCount, setInverterCount] = useState(''); const [inverterCount, setInverterCount] = useState('');
@ -250,46 +249,6 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
</> </>
)} )}
<div>
<FormControl
fullWidth
sx={{
marginTop: 1,
marginBottom: 1,
width: 390
}}
>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="dataCollectionEnabled"
defaultMessage="Data Collection"
/>
</InputLabel>
<Select
name="dataCollectionEnabled"
value={formValues.dataCollectionEnabled ? 'yes' : 'no'}
onChange={(e) =>
setFormValues({
...formValues,
dataCollectionEnabled: e.target.value === 'yes'
})
}
>
<MenuItem value="yes">
<FormattedMessage id="yes" defaultMessage="Yes" />
</MenuItem>
<MenuItem value="no">
<FormattedMessage id="no" defaultMessage="No" />
</MenuItem>
</Select>
</FormControl>
</div>
</Box> </Box>
<div <div
style={{ style={{

View File

@ -275,12 +275,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
const isGrowatt = currentInstallation?.device === 3 const isGrowatt = currentInstallation?.device === 3
|| (installations.length === 1 && installations[0].device === 3); || (installations.length === 1 && installations[0].device === 3);
// When data collection is disabled, only navigation, info, history, tickets, documents remain.
const dataCollectionDisabled =
currentInstallation?.dataCollectionEnabled === false
|| (installations.length === 1 && installations[0].dataCollectionEnabled === false);
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents'];
const tabs = inInstallationView && currentUser.userType == UserType.admin const tabs = inInstallationView && currentUser.userType == UserType.admin
? [ ? [
{ {
@ -477,7 +471,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
> >
{tabs {tabs
.filter((tab) => !(isGrowatt && tab.value === 'report')) .filter((tab) => !(isGrowatt && tab.value === 'report'))
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
.map((tab) => ( .map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}
@ -551,7 +544,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
> >
{singleInstallationTabs {singleInstallationTabs
.filter((tab) => !(isGrowatt && tab.value === 'report')) .filter((tab) => !(isGrowatt && tab.value === 'report'))
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
.map((tab) => ( .map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}

View File

@ -7,20 +7,15 @@ import {
CardContent, CardContent,
CardHeader, CardHeader,
Chip, Chip,
ClickAwayListener,
Divider, Divider,
LinearProgress, LinearProgress,
MenuItem,
MenuList,
Paper,
Popper,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import SmartToyIcon from '@mui/icons-material/SmartToy'; import SmartToyIcon from '@mui/icons-material/SmartToy';
import AttachFileIcon from '@mui/icons-material/AttachFile'; import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
import DocumentList from 'src/components/DocumentList'; import DocumentList from 'src/components/DocumentList';
@ -38,7 +33,6 @@ function CommentThread({
onCommentAdded, onCommentAdded,
adminUsers = [] adminUsers = []
}: CommentThreadProps) { }: CommentThreadProps) {
const intl = useIntl();
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -46,68 +40,6 @@ function CommentThread({
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const commentInputRef = useRef<HTMLInputElement | null>(null);
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
const mentionCandidates = mentionQuery === null
? []
: adminUsers
.filter((u) =>
!MENTION_EXCLUDED_NAMES.includes(u.name) &&
u.name.toLowerCase().includes(mentionQuery.toLowerCase()) &&
!mentionedIds.includes(u.id)
)
.slice(0, 8);
const detectMention = (text: string, cursor: number) => {
const upToCursor = text.slice(0, cursor);
const atIdx = upToCursor.lastIndexOf('@');
if (atIdx === -1) return null;
const between = upToCursor.slice(atIdx + 1);
if (/\s/.test(between)) return null;
const prevChar = atIdx === 0 ? ' ' : upToCursor[atIdx - 1];
if (!/\s|^$/.test(prevChar) && atIdx !== 0) return null;
return { atIdx, query: between };
};
const handleBodyChange = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const text = e.target.value;
const cursor = e.target.selectionStart ?? text.length;
setBody(text);
const match = detectMention(text, cursor);
setMentionQuery(match ? match.query : null);
// Drop mentioned IDs whose display names no longer appear in the body
setMentionedIds((prev) =>
prev.filter((uid) => {
const u = adminUsers.find((au) => au.id === uid);
return u ? text.includes(`@${u.name}`) : false;
})
);
};
const handleSelectMention = (userId: number, userName: string) => {
const input = commentInputRef.current;
const cursor = input?.selectionStart ?? body.length;
const match = detectMention(body, cursor);
if (!match) return;
const before = body.slice(0, match.atIdx);
const after = body.slice(cursor);
const token = `@${userName} `;
const next = `${before}${token}${after}`;
setBody(next);
setMentionedIds((prev) => (prev.includes(userId) ? prev : [...prev, userId]));
setMentionQuery(null);
const caret = before.length + token.length;
setTimeout(() => {
input?.focus();
input?.setSelectionRange(caret, caret);
}, 0);
};
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; const MAX_FILE_SIZE = 25 * 1024 * 1024;
@ -132,15 +64,7 @@ function CommentThread({
try { try {
let commentId: number | undefined; let commentId: number | undefined;
if (body.trim()) { if (body.trim()) {
const activeMentionedIds = mentionedIds.filter((uid) => { const res = await axiosConfig.post('/AddTicketComment', { ticketId, body });
const u = adminUsers.find((au) => au.id === uid);
return u ? body.includes(`@${u.name}`) : false;
});
const res = await axiosConfig.post('/AddTicketComment', {
ticketId,
body,
mentionedUserIds: activeMentionedIds
});
commentId = res.data?.id; commentId = res.data?.id;
} }
@ -166,8 +90,6 @@ function CommentThread({
} }
setBody(''); setBody('');
setMentionedIds([]);
setMentionQuery(null);
setSelectedFiles([]); setSelectedFiles([]);
setRefreshKey((k) => k + 1); setRefreshKey((k) => k + 1);
onCommentAdded(); onCommentAdded();
@ -244,35 +166,10 @@ function CommentThread({
multiline multiline
minRows={2} minRows={2}
maxRows={4} maxRows={4}
placeholder={intl.formatMessage({ placeholder="Add a comment..."
id: 'mentionPlaceholder',
defaultMessage: 'Type @ to mention a user'
})}
value={body} value={body}
onChange={handleBodyChange} onChange={(e) => setBody(e.target.value)}
inputRef={commentInputRef}
/> />
<Popper
open={mentionQuery !== null && mentionCandidates.length > 0}
anchorEl={commentInputRef.current}
placement="top-start"
style={{ zIndex: 1300 }}
>
<ClickAwayListener onClickAway={() => setMentionQuery(null)}>
<Paper elevation={4} sx={{ minWidth: 200, maxHeight: 240, overflowY: 'auto' }}>
<MenuList dense>
{mentionCandidates.map((u) => (
<MenuItem
key={u.id}
onClick={() => handleSelectMention(u.id, u.name)}
>
{u.name}
</MenuItem>
))}
</MenuList>
</Paper>
</ClickAwayListener>
</Popper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
<input <input
ref={fileInputRef} ref={fileInputRef}

View File

@ -19,7 +19,7 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import AttachFileIcon from '@mui/icons-material/AttachFile'; import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import { import {
TicketPriority, TicketPriority,
@ -28,8 +28,7 @@ import {
subCategoryLabels, subCategoryLabels,
subCategoriesByCategory, subCategoriesByCategory,
categoryLabels, categoryLabels,
otherSubCategoryValues, otherSubCategoryValues
AdminUser
} from 'src/interfaces/TicketTypes'; } from 'src/interfaces/TicketTypes';
type Installation = { type Installation = {
@ -66,7 +65,6 @@ interface Props {
} }
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) { function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
const intl = useIntl();
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [selectedProduct, setSelectedProduct] = useState<number | ''>(''); const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
const [selectedDevice, setSelectedDevice] = useState<number | ''>(''); const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
@ -75,8 +73,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
useState<Installation | null>(null); useState<Installation | null>(null);
const [loadingInstallations, setLoadingInstallations] = useState(false); const [loadingInstallations, setLoadingInstallations] = useState(false);
const [priority, setPriority] = useState<number>(TicketPriority.Medium); const [priority, setPriority] = useState<number>(TicketPriority.Medium);
const [assigneeId, setAssigneeId] = useState<number | ''>('');
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [category, setCategory] = useState<number>(TicketCategory.Hardware); const [category, setCategory] = useState<number>(TicketCategory.Hardware);
const [subCategory, setSubCategory] = useState<number>( const [subCategory, setSubCategory] = useState<number>(
TicketSubCategory.Battery TicketSubCategory.Battery
@ -193,16 +189,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
.finally(() => setLoadingInstallations(false)); .finally(() => setLoadingInstallations(false));
}, [selectedProduct]); }, [selectedProduct]);
useEffect(() => {
if (!open) return;
axiosConfig
.get('/GetAdminUsers')
.then((res) => {
if (Array.isArray(res.data)) setAdminUsers(res.data);
})
.catch(() => setAdminUsers([]));
}, [open]);
useEffect(() => { useEffect(() => {
if (defaultInstallationId == null || !open) return; if (defaultInstallationId == null || !open) return;
axiosConfig axiosConfig
@ -247,7 +233,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
setAllInstallations([]); setAllInstallations([]);
setSelectedInstallation(null); setSelectedInstallation(null);
setPriority(TicketPriority.Medium); setPriority(TicketPriority.Medium);
setAssigneeId('');
setCategory(TicketCategory.Hardware); setCategory(TicketCategory.Hardware);
setSubCategory(TicketSubCategory.Battery); setSubCategory(TicketSubCategory.Battery);
setDescription(''); setDescription('');
@ -259,15 +244,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
const handleSubmit = async () => { const handleSubmit = async () => {
if (!subject.trim()) return; if (!subject.trim()) return;
if (assigneeId === '') {
setError(
intl.formatMessage({
id: 'assigneeRequired',
defaultMessage: 'Please assign this ticket to someone before creating it.'
})
);
return;
}
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
@ -277,7 +253,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
description, description,
installationId: selectedInstallation?.id ?? null, installationId: selectedInstallation?.id ?? null,
priority, priority,
assigneeId,
category, category,
subCategory: isOtherCategory ? 0 : subCategory, subCategory: isOtherCategory ? 0 : subCategory,
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null, customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
@ -415,33 +390,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
)} )}
/> />
<FormControl fullWidth margin="dense" required error={assigneeId === ''}>
<InputLabel>
<FormattedMessage id="assignee" defaultMessage="Assignee" />
</InputLabel>
<Select
value={assigneeId}
label="Assignee"
onChange={(e) =>
setAssigneeId(e.target.value === '' ? '' : Number(e.target.value))
}
>
{adminUsers
.filter((u) => {
const name = (u.name ?? '').toLowerCase();
return (
!name.includes('inesco energy master admin') &&
!name.includes('paal myhre')
);
})
.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="dense"> <FormControl fullWidth margin="dense">
<InputLabel> <InputLabel>
<FormattedMessage id="priority" defaultMessage="Priority" /> <FormattedMessage id="priority" defaultMessage="Priority" />

View File

@ -32,7 +32,9 @@ const statusCountKeys: {
}[] = [ }[] = [
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' }, { status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' }, { status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved', color: '#2e7d32' } { status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
]; ];
function InstallationTicketsTab({ installationId }: Props) { function InstallationTicketsTab({ installationId }: Props) {

View File

@ -6,7 +6,7 @@ const statusLabels: Record<number, string> = {
[TicketStatus.Open]: 'Open', [TicketStatus.Open]: 'Open',
[TicketStatus.InProgress]: 'In Progress', [TicketStatus.InProgress]: 'In Progress',
[TicketStatus.Escalated]: 'Escalated', [TicketStatus.Escalated]: 'Escalated',
[TicketStatus.Resolved]: 'Solved', [TicketStatus.Resolved]: 'Resolved',
[TicketStatus.Closed]: 'Closed' [TicketStatus.Closed]: 'Closed'
}; };

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { import {
Alert, Alert,
@ -63,7 +63,9 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [ const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' }, { value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' }, { value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' } { value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
]; ];
function TicketDetailPage() { function TicketDetailPage() {
@ -88,9 +90,6 @@ function TicketDetailPage() {
const [savingDescription, setSavingDescription] = useState(false); const [savingDescription, setSavingDescription] = useState(false);
const [descriptionSaved, setDescriptionSaved] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false);
const [docRefreshKey, setDocRefreshKey] = useState(0); const [docRefreshKey, setDocRefreshKey] = useState(0);
const [solveGateOpen, setSolveGateOpen] = useState(false);
const rootCauseRef = useRef<HTMLInputElement | null>(null);
const solutionRef = useRef<HTMLInputElement | null>(null);
// Custom "Other" editing state // Custom "Other" editing state
const [editCustomSub, setEditCustomSub] = useState(''); const [editCustomSub, setEditCustomSub] = useState('');
@ -154,7 +153,9 @@ function TicketDetailPage() {
newStatus === TicketStatus.Resolved && newStatus === TicketStatus.Resolved &&
(!rootCause.trim() || !solution.trim()) (!rootCause.trim() || !solution.trim())
) { ) {
setSolveGateOpen(true); setResolutionError(
'Root Cause and Solution are required to resolve a ticket.'
);
return; return;
} }
setResolutionError(''); setResolutionError('');
@ -474,7 +475,6 @@ function TicketDetailPage() {
error={ error={
!!resolutionError && !rootCause.trim() !!resolutionError && !rootCause.trim()
} }
inputRef={rootCauseRef}
/> />
<TextField <TextField
label={ label={
@ -491,7 +491,6 @@ function TicketDetailPage() {
error={ error={
!!resolutionError && !solution.trim() !!resolutionError && !solution.trim()
} }
inputRef={solutionRef}
/> />
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}> <Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
{resolutionSaved && ( {resolutionSaved && (
@ -592,19 +591,11 @@ function TicketDetailPage() {
/> />
</em> </em>
</MenuItem> </MenuItem>
{adminUsers {adminUsers.map((u) => (
.filter((u) => { <MenuItem key={u.id} value={u.id}>
const name = (u.name ?? '').toLowerCase(); {u.name}
return ( </MenuItem>
!name.includes('inesco energy master admin') && ))}
!name.includes('paal myhre')
);
})
.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
@ -878,36 +869,6 @@ function TicketDetailPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Solve-gate reminder dialog */}
<Dialog open={solveGateOpen} onClose={() => setSolveGateOpen(false)}>
<DialogTitle>
<FormattedMessage
id="solveGateTitle"
defaultMessage="Root Cause and Solution required"
/>
</DialogTitle>
<DialogContent>
<DialogContentText>
<FormattedMessage
id="solveGateBody"
defaultMessage="To mark this ticket as Solved, please fill in both Root Cause and Solution before saving."
/>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
variant="contained"
onClick={() => {
setSolveGateOpen(false);
if (!rootCause.trim()) rootCauseRef.current?.focus();
else solutionRef.current?.focus();
}}
>
<FormattedMessage id="solveGateOk" defaultMessage="OK" />
</Button>
</DialogActions>
</Dialog>
</Container> </Container>
<Footer /> <Footer />
</div> </div>

View File

@ -37,7 +37,9 @@ import StatusChip from './StatusChip';
const statusKeys: Record<number, { id: string; defaultMessage: string }> = { const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' }, [TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' }, [TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Solved' } [TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
}; };
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = { const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {

View File

@ -42,21 +42,21 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
const inv = props.values?.InverterRecord; const inv = props.values?.InverterRecord;
const hasDevices = !!inv?.Devices; const hasDevices = !!inv?.Devices;
const growattActiveIndices: number[] = hasDevices
? []
: Array.from({ length: props.batteryClusterNumber }, (_, i) => i + 1)
.filter((i) => Number(inv?.[`Battery${i}Voltage`]) > 0);
const totalBatteryPower: number = hasDevices const totalBatteryPower: number = hasDevices
? (inv?.TotalBatteryPower ?? 0) ? (inv?.TotalBatteryPower ?? 0)
: growattActiveIndices.reduce( : Number(
(sum, i) => sum + (Number(inv?.[`Battery${i}Power`]) || 0), Array.from({ length: props.batteryClusterNumber }).reduce(
0 (sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0),
0
)
); );
const pvPower: number = hasDevices const pvPower: number = hasDevices
? (inv?.TotalPhotovoltaicPower ?? 0) ? (inv?.TotalPhotovoltaicPower ?? 0)
: (inv?.PvPower ?? 0); : (inv?.PvPower ??
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
.map((key) => inv?.[key] ?? 0)
.reduce((sum, val) => sum + val, 0));
const totalLoadPower: number = hasDevices const totalLoadPower: number = hasDevices
? (inv?.TotalLoadPower ?? 0) ? (inv?.TotalLoadPower ?? 0)
@ -65,15 +65,6 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
const totalGridPower: number = const totalGridPower: number =
inv?.TotalGridPower ?? inv?.GridPower ?? 0; inv?.TotalGridPower ?? inv?.GridPower ?? 0;
const avgBatterySoc: number = hasDevices
? (inv?.AvgBatterySoc ?? 0)
: (growattActiveIndices.length
? growattActiveIndices.reduce(
(sum, i) => sum + (Number(inv?.[`Battery${i}Soc`]) || 0),
0
) / growattActiveIndices.length
: 0);
return ( return (
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}> <Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
<Grid container> <Grid container>
@ -264,20 +255,42 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
isFirst={false} isFirst={false}
/> />
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/} {/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
<TopologyColumn {Array.from({ length: props.batteryClusterNumber }).map((_, index) => {
centerBox={{ let soc: number;
title: 'Battery', let power: number;
data: inv
? [ if (hasDevices) {
{ value: avgBatterySoc, unit: '%' }, // Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
{ value: totalBatteryPower, unit: 'W' } const deviceId = String(Math.floor(index / 2) + 1);
] const batteryIndex = (index % 2) + 1;
: undefined, const device = inv?.Devices?.[deviceId];
connected: true soc = device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0;
}} power = device?.[`Battery${batteryIndex}Power`] ?? 0;
isFirst={false} } else {
isLast={true} // 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}
/>
);
})}
</Grid> </Grid>
</> </>
)} )}

View File

@ -257,11 +257,14 @@ function TopologyBox(props: TopologyBoxProps) {
}} }}
> >
{props.data.map((boxData, index) => { {props.data.map((boxData, index) => {
const formatted = formatPower(boxData.value, boxData.unit);
return ( return (
<Typography key={index}> <Typography key={index}>
{formatted === 0 ? '0 ' : formatted} {formatPower(boxData.value, boxData.unit) === 0
{boxData.unit} ? null
: formatPower(boxData.value, boxData.unit)}
{formatPower(boxData.value, boxData.unit) === 0
? null
: boxData.unit}
</Typography> </Typography>
); );
})} })}

View File

@ -160,64 +160,50 @@ function CustomTreeItem(props: CustomTreeItemProps) {
{props.node.type === 'Installation' && ( {props.node.type === 'Installation' && (
<div> <div>
{(props.node as any).dataCollectionEnabled === false ? ( {status === -1 ? (
<div <CancelIcon
style={{ style={{
width: '20px', width: '23px',
height: '20px', height: '23px',
color: 'red',
borderRadius: '50%', borderRadius: '50%',
marginLeft: '17px', marginLeft: '30px',
backgroundColor: 'grey' marginTop: '30px'
}} }}
/> />
) : ( ) : (
<> ''
{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'
}}
/>
) : (
''
)}
<div
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
marginLeft: '17px',
backgroundColor:
status === 2
? 'red'
: status === 1
? 'orange'
: status === -1 || status === -2
? 'transparent'
: 'green'
}}
/>
</>
)} )}
{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> </div>
)} )}
</div> </div>

View File

@ -17,16 +17,12 @@ function InstallationTree() {
useContext(InstallationsContext); useContext(InstallationsContext);
const sortedInstallations = [...foldersAndInstallations].sort((a, b) => { const sortedInstallations = [...foldersAndInstallations].sort((a, b) => {
// Folders stay on top (existing behavior). // Compare the status field of each installation and sort them based on the status.
//Installations with alarms go first
if (a.type == 'Folder') { if (a.type == 'Folder') {
return -1; return -1;
} }
// Data-collection-disabled installations sink below everything (even offline).
const aDisabled = (a as any).dataCollectionEnabled === false;
const bDisabled = (b as any).dataCollectionEnabled === false;
if (aDisabled !== bDisabled) return aDisabled ? 1 : -1;
// Then sort by status (alarms first).
let a_status = a.status; let a_status = a.status;
let b_status = b.status; let b_status = b.status;

View File

@ -36,7 +36,6 @@ export interface I_Installation extends I_S3Credentials {
product: number; product: number;
device: number; device: number;
testingMode?: boolean; testingMode?: boolean;
dataCollectionEnabled?: boolean;
status?: number; status?: number;
serialNumber?: string; serialNumber?: string;
networkProvider: string; networkProvider: string;

View File

@ -86,9 +86,6 @@
"externalEmsOther": "Externes EMS (angeben)", "externalEmsOther": "Externes EMS (angeben)",
"emsNo": "Nein", "emsNo": "Nein",
"emsOther": "Andere", "emsOther": "Andere",
"yes": "Ja",
"no": "Nein",
"dataCollectionEnabled": "Datenerfassung",
"generalInfo": "Allgemeine Informationen", "generalInfo": "Allgemeine Informationen",
"installationSetup": "Installationseinrichtung", "installationSetup": "Installationseinrichtung",
"couplingType": "AC/DC-Kopplung", "couplingType": "AC/DC-Kopplung",
@ -577,7 +574,6 @@
"resolvedAt": "Gelöst", "resolvedAt": "Gelöst",
"noDescription": "Keine Beschreibung vorhanden.", "noDescription": "Keine Beschreibung vorhanden.",
"assignee": "Zuständig", "assignee": "Zuständig",
"assigneeRequired": "Bitte weisen Sie dieses Ticket jemandem zu, bevor Sie es erstellen.",
"unassigned": "Nicht zugewiesen", "unassigned": "Nicht zugewiesen",
"deleteTicket": "Löschen", "deleteTicket": "Löschen",
"confirmDeleteTicket": "Ticket löschen?", "confirmDeleteTicket": "Ticket löschen?",
@ -598,11 +594,6 @@
"statusEscalated": "Eskaliert", "statusEscalated": "Eskaliert",
"statusResolved": "Gelöst", "statusResolved": "Gelöst",
"statusClosed": "Geschlossen", "statusClosed": "Geschlossen",
"solveGateTitle": "Ursache und Lösung erforderlich",
"solveGateBody": "Um dieses Ticket als gelöst zu markieren, bitte sowohl Ursache als auch Lösung ausfüllen, bevor Sie speichern.",
"solveGateOk": "OK",
"mentionPlaceholder": "@ eingeben, um einen Benutzer zu erwähnen",
"mentionNoResults": "Keine Benutzer gefunden",
"priorityCritical": "Kritisch", "priorityCritical": "Kritisch",
"priorityHigh": "Hoch", "priorityHigh": "Hoch",
"priorityMedium": "Mittel", "priorityMedium": "Mittel",

View File

@ -68,9 +68,6 @@
"externalEmsOther": "External EMS (specify)", "externalEmsOther": "External EMS (specify)",
"emsNo": "No", "emsNo": "No",
"emsOther": "Other", "emsOther": "Other",
"yes": "Yes",
"no": "No",
"dataCollectionEnabled": "Data Collection",
"generalInfo": "General Info", "generalInfo": "General Info",
"installationSetup": "Installation Setup", "installationSetup": "Installation Setup",
"couplingType": "AC/DC Coupling", "couplingType": "AC/DC Coupling",
@ -325,7 +322,6 @@
"resolvedAt": "Resolved", "resolvedAt": "Resolved",
"noDescription": "No description provided.", "noDescription": "No description provided.",
"assignee": "Assignee", "assignee": "Assignee",
"assigneeRequired": "Please assign this ticket to someone before creating it.",
"unassigned": "Unassigned", "unassigned": "Unassigned",
"deleteTicket": "Delete", "deleteTicket": "Delete",
"confirmDeleteTicket": "Delete Ticket?", "confirmDeleteTicket": "Delete Ticket?",
@ -344,13 +340,8 @@
"statusOpen": "Open", "statusOpen": "Open",
"statusInProgress": "In Progress", "statusInProgress": "In Progress",
"statusEscalated": "Escalated", "statusEscalated": "Escalated",
"statusResolved": "Solved", "statusResolved": "Resolved",
"statusClosed": "Closed", "statusClosed": "Closed",
"solveGateTitle": "Root Cause and Solution required",
"solveGateBody": "To mark this ticket as Solved, please fill in both Root Cause and Solution before saving.",
"solveGateOk": "OK",
"mentionPlaceholder": "Type @ to mention a user",
"mentionNoResults": "No users found",
"priorityCritical": "Critical", "priorityCritical": "Critical",
"priorityHigh": "High", "priorityHigh": "High",
"priorityMedium": "Medium", "priorityMedium": "Medium",

View File

@ -80,9 +80,6 @@
"externalEmsOther": "EMS externe (préciser)", "externalEmsOther": "EMS externe (préciser)",
"emsNo": "Non", "emsNo": "Non",
"emsOther": "Autre", "emsOther": "Autre",
"yes": "Oui",
"no": "Non",
"dataCollectionEnabled": "Collecte de données",
"generalInfo": "Informations générales", "generalInfo": "Informations générales",
"installationSetup": "Configuration de l'installation", "installationSetup": "Configuration de l'installation",
"couplingType": "Couplage AC/DC", "couplingType": "Couplage AC/DC",
@ -577,7 +574,6 @@
"resolvedAt": "Résolu", "resolvedAt": "Résolu",
"noDescription": "Aucune description fournie.", "noDescription": "Aucune description fournie.",
"assignee": "Responsable", "assignee": "Responsable",
"assigneeRequired": "Veuillez assigner ce ticket à quelqu'un avant de le créer.",
"unassigned": "Non assigné", "unassigned": "Non assigné",
"deleteTicket": "Supprimer", "deleteTicket": "Supprimer",
"confirmDeleteTicket": "Supprimer le ticket ?", "confirmDeleteTicket": "Supprimer le ticket ?",
@ -598,11 +594,6 @@
"statusEscalated": "Escaladé", "statusEscalated": "Escaladé",
"statusResolved": "Résolu", "statusResolved": "Résolu",
"statusClosed": "Fermé", "statusClosed": "Fermé",
"solveGateTitle": "Cause et solution requises",
"solveGateBody": "Pour marquer ce ticket comme résolu, veuillez renseigner la cause et la solution avant d'enregistrer.",
"solveGateOk": "OK",
"mentionPlaceholder": "Tapez @ pour mentionner un utilisateur",
"mentionNoResults": "Aucun utilisateur trouvé",
"priorityCritical": "Critique", "priorityCritical": "Critique",
"priorityHigh": "Élevée", "priorityHigh": "Élevée",
"priorityMedium": "Moyenne", "priorityMedium": "Moyenne",

View File

@ -68,9 +68,6 @@
"externalEmsOther": "EMS esterno (specificare)", "externalEmsOther": "EMS esterno (specificare)",
"emsNo": "No", "emsNo": "No",
"emsOther": "Altro", "emsOther": "Altro",
"yes": "Sì",
"no": "No",
"dataCollectionEnabled": "Raccolta dati",
"generalInfo": "Informazioni generali", "generalInfo": "Informazioni generali",
"installationSetup": "Configurazione installazione", "installationSetup": "Configurazione installazione",
"couplingType": "Accoppiamento AC/DC", "couplingType": "Accoppiamento AC/DC",
@ -577,7 +574,6 @@
"resolvedAt": "Risolto", "resolvedAt": "Risolto",
"noDescription": "Nessuna descrizione fornita.", "noDescription": "Nessuna descrizione fornita.",
"assignee": "Assegnatario", "assignee": "Assegnatario",
"assigneeRequired": "Assegna questo ticket a qualcuno prima di crearlo.",
"unassigned": "Non assegnato", "unassigned": "Non assegnato",
"deleteTicket": "Elimina", "deleteTicket": "Elimina",
"confirmDeleteTicket": "Eliminare il ticket?", "confirmDeleteTicket": "Eliminare il ticket?",
@ -598,11 +594,6 @@
"statusEscalated": "Escalato", "statusEscalated": "Escalato",
"statusResolved": "Risolto", "statusResolved": "Risolto",
"statusClosed": "Chiuso", "statusClosed": "Chiuso",
"solveGateTitle": "Causa e soluzione richieste",
"solveGateBody": "Per contrassegnare questo ticket come risolto, compilare sia la causa sia la soluzione prima di salvare.",
"solveGateOk": "OK",
"mentionPlaceholder": "Digita @ per menzionare un utente",
"mentionNoResults": "Nessun utente trovato",
"priorityCritical": "Critica", "priorityCritical": "Critica",
"priorityHigh": "Alta", "priorityHigh": "Alta",
"priorityMedium": "Media", "priorityMedium": "Media",