Compare commits

...

37 Commits

Author SHA1 Message Date
Yinyin Liu bf60286356 fix detele daily data by accident 2026-06-18 15:32:11 +02:00
Yinyin Liu b0e3e47553 add a role column and filter for quick view in Users page 2026-06-16 14:54:45 +02:00
Yinyin Liu 4aa30acd0b not-set instead of default value for AC/DC Coupling and Backup Load 2026-06-16 14:36:04 +02:00
Yinyin Liu 15972cff1d change user's email address process 2026-06-16 13:24:37 +02:00
Yinyin Liu 7654191ea6 remove connection check for on-site checklist which should always be available 2026-06-16 12:05:05 +02:00
Yinyin Liu cd02585402 add remove access reminder for Users 2026-06-16 09:43:10 +02:00
Yinyin Liu 8886193889 digital inputs for sinexcel sodistore home and pro 2026-06-16 09:11:47 +02:00
Yinyin Liu ffc5b12410 better group distribution partner in Tickets 2026-06-15 10:43:55 +02:00
Yinyin Liu de73bc9211 add link to partner/client's installationticket page from users page 2026-06-15 10:30:39 +02:00
Yinyin Liu 444a0a29f2 add backup load in information tab 2026-06-12 13:48:16 +02:00
Yinyin Liu b09cf00d78 fix of backend to get 15-min data 2026-06-12 13:28:50 +02:00
Yinyin Liu 807882b960 get price data point every 15 min 2026-06-12 13:05:43 +02:00
Yinyin Liu c0fa353a17 zoom in and zoom out of price chart 2026-06-12 12:38:38 +02:00
Yinyin Liu 4afeceea5d current price chart 2026-06-11 16:32:49 +02:00
Yinyin Liu 879d848ed9 Merge branch 'feature/centralized-alarm-report' into main 2026-06-10 10:52:03 +02:00
Yinyin Liu bf0aa4d959 add link from alarm summary to installation itself 2026-06-09 10:51:18 +02:00
Yinyin Liu aefeb75641 warning overview page for sodistore home and pro 2026-06-09 10:22:48 +02:00
Yinyin Liu ec0e8258b2 clean installation flat view 2026-06-05 09:33:12 +02:00
Yinyin Liu 8c01912947 battery voltage monitor 2026-06-02 15:18:37 +02:00
Yinyin Liu 67d5091093 dynamic window size of user list 2026-06-02 13:43:52 +02:00
Yinyin Liu 2cc8eebf37 grey out main admin and the user himself in avoidance of deleting the account by accident 2026-06-02 10:06:04 +02:00
Yinyin Liu 3a5c203664 allow admin to see all users even not self created, admin can delete admin accounts 2026-06-02 09:37:04 +02:00
Yinyin Liu edb30286fa changes to monitor onboarding checklist 2026-06-02 08:29:20 +02:00
Yinyin Liu 88757c562b comment out alarm auto-ticket trigger 2026-06-01 08:25:41 +02:00
Yinyin Liu e3248090da Merge branch 'feature/sodistorehome-onsite-checklist' into main 2026-05-29 16:06:39 +02:00
Yinyin Liu 6f1e8f05f6 remove step 9 2026-05-29 15:57:50 +02:00
Yinyin Liu ed5ec0afa2 on site checklist on monitor 2026-05-28 08:27:25 +02:00
Yinyin Liu 646b6c0e20 add 4 relay controls on configuration tab for sinexcel of sodistore home and pro 2026-05-22 09:43:38 +02:00
Yinyin Liu b11e3340fd add miexed as the third option for AC/DC coupled 2026-05-22 09:20:19 +02:00
Yinyin Liu 77abe03f9c design new configuration tab for sodistore grid and meerge to main branch 2026-05-19 17:05:58 +02:00
Yinyin Liu 8b8fe8cf2e Merge branch 'feature/sodistoregrid-live-view' into main 2026-05-19 16:25:26 +02:00
Yinyin Liu 2e137cf550 Merge branch 'main' into feature/sodistoregrid-live-view 2026-05-19 16:24:20 +02:00
Yinyin Liu 4fa5ba60c8 empty cluster goes with no battery limitation settings 2026-05-19 13:36:58 +02:00
Yinyin Liu 2e43abc947 remove checklist and add confguration tab 2026-05-19 12:33:08 +02:00
Yinyin Liu 3c8b05bbf9 comment Last Week button since there is no aggregated data 2026-05-19 09:36:12 +02:00
Yinyin Liu c1b456639a fix Last Week issue 2026-05-19 09:10:15 +02:00
Yinyin Liu 74eaa258e1 sodistore grid 2026-05-18 17:14:14 +02:00
80 changed files with 5866 additions and 285 deletions

View File

@ -154,8 +154,121 @@ public class Controller : ControllerBase
.ThenByDescending(error => error.Time)
.ToList();
}
// Admin-only centralized alarm log across all accessible installations.
// Server-side filtering (product/device/severity/date/installation); the frontend
// does the grouping (Installation+Device+Alarm+Severity) and sorting.
// NOTE: the underlying Error/Warning tables keep only the last ~100 rows per
// installation (see Db.HandleError/HandleWarning), so this report reflects
// currently-retained alarms, not a guaranteed full history.
[HttpGet(nameof(GetAlarmReport))]
public ActionResult<IEnumerable<AlarmReportRow>> GetAlarmReport(Token authToken,
String? products = null,
String? devices = null,
String severity = "both",
String? from = null,
String? to = null,
Int64? installationId = null)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) // admins only
return Unauthorized();
var productSet = ParseIntCsv(products);
var deviceSet = ParseIntCsv(devices);
// This is a battery alarm report for the Sodistore series only:
// products Sodistore Home + Pro, devices Growatt (3) + Sinexcel (4).
var allowedProducts = new HashSet<int> { (int)ProductType.SodioHome, (int)ProductType.SodistorePro };
var allowedDevices = new HashSet<int> { 3, 4 };
var installations = user
.AccessibleInstallations()
.Where(i => allowedProducts.Contains(i.Product))
.Where(i => allowedDevices.Contains(i.Device))
.Where(i => productSet is null || productSet.Contains(i.Product))
.Where(i => deviceSet is null || deviceSet.Contains(i.Device))
.Where(i => installationId is null || i.Id == installationId)
.ToList();
// GroupBy (not a plain ToDictionary): AccessibleInstallations() concats direct +
// folder-derived installations and .Distinct()s by reference, so the same Id can
// appear as two instances. GroupBy collapses them and avoids a duplicate-key throw.
var byId = installations
.GroupBy(i => i.Id)
.ToDictionary(g => g.Key, g => g.First());
var ids = byId.Keys.ToHashSet();
if (ids.Count == 0)
return new List<AlarmReportRow>();
var rows = new List<AlarmReportRow>();
var wantErrors = severity is "both" or "error";
var wantWarnings = severity is "both" or "warning";
// Query per accessible installation so sqlite-net pushes "InstallationId == x"
// down to SQL (HashSet.Contains can't be translated and would scan the whole table).
foreach (var instId in ids)
{
if (wantErrors)
rows.AddRange(Db.Errors
.Where(e => e.InstallationId == instId)
.AsEnumerable()
.Select(e => ToReportRow(e, "error", byId)));
if (wantWarnings)
rows.AddRange(Db.Warnings
.Where(w => w.InstallationId == instId)
.AsEnumerable()
.Select(w => ToReportRow(w, "warning", byId)));
}
// Date stored as "yyyy-MM-dd": lexical compare == chronological, no parsing needed.
if (!String.IsNullOrWhiteSpace(from))
rows = rows.Where(r => String.Compare(r.Date, from, StringComparison.Ordinal) >= 0).ToList();
if (!String.IsNullOrWhiteSpace(to))
rows = rows.Where(r => String.Compare(r.Date, to, StringComparison.Ordinal) <= 0).ToList();
return rows
.OrderByDescending(r => r.Date)
.ThenByDescending(r => r.Time)
.ToList();
}
private static AlarmReportRow ToReportRow(LogEntry entry, String severity, IReadOnlyDictionary<Int64, Installation> byId)
{
var installation = byId.TryGetValue(entry.InstallationId, out var inst) ? inst : null;
return new AlarmReportRow
{
Id = entry.Id,
InstallationId = entry.InstallationId,
InstallationName = installation?.Name ?? "",
Product = installation?.Product ?? -1,
Device = installation?.Device ?? -1,
Severity = severity,
Description = entry.Description,
Date = entry.Date,
Time = entry.Time,
DeviceCreatedTheMessage = entry.DeviceCreatedTheMessage,
Seen = entry.Seen,
};
}
private static HashSet<int>? ParseIntCsv(String? csv)
{
if (String.IsNullOrWhiteSpace(csv))
return null;
return csv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var v) ? (int?)v : null)
.Where(v => v.HasValue)
.Select(v => v!.Value)
.ToHashSet();
}
[HttpGet(nameof(GetCsvTimestampsForInstallation))]
public ActionResult<IEnumerable<Int64>> GetCsvTimestampsForInstallation(Int64 id, Int32 start, Int32 end, Token authToken)
@ -302,6 +415,34 @@ public class Controller : ControllerBase
return sampledTimestamps;
}
// Dynamic-pricing "Current Price" history (CHF/kWh) for the Configuration tab chart.
// Only Growatt (device 3) and Sinexcel (device 4) carry Config.CurrentPrice.
[HttpGet(nameof(GetCurrentPriceHistory))]
public async Task<ActionResult<IEnumerable<PricePoint>>> GetCurrentPriceHistory(Int64 id, Int64 start, Int64 end, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(id);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
if (installation.Device != 3 && installation.Device != 4)
return Ok(new List<PricePoint>());
// Clamp the range: each day spawns s3cmd processes + S3 fetches, so an
// unbounded range from a crafted request could exhaust the single prod backend.
const Int64 maxRangeSeconds = 31L * 86400;
if (start < 0 || end <= start)
return Ok(new List<PricePoint>());
if (end - start > maxRangeSeconds)
start = end - maxRangeSeconds;
var history = await CurrentPriceHistoryService.GetHistory(installation, start, end);
return Ok(history);
}
[HttpGet(nameof(GetUserById))]
public ActionResult<User> GetUserById(Int64 id, Token authToken)
{
@ -381,7 +522,7 @@ public class Controller : ControllerBase
return Unauthorized();
return user.DirectlyAccessibleInstallations()
.Select(i => new { i.Id, i.Name })
.Select(i => new { i.Id, i.Name, i.Product })
.ToList<Object>();
}
@ -401,6 +542,24 @@ public class Controller : ControllerBase
.ToList<Object>();
}
[HttpGet(nameof(GetInstallationsUnderFolder))]
public ActionResult<IEnumerable<Object>> GetInstallationsUnderFolder(Int64 folderId, Token authToken)
{
var sessionUser = Db.GetSession(authToken)?.User;
if (sessionUser == null)
return Unauthorized();
var folder = Db.GetFolderById(folderId);
if (folder == null || !sessionUser.HasAccessTo(folder))
return Unauthorized();
return folder
.DescendantFoldersAndSelf() // self + all nested subfolders
.SelectMany(f => f.ChildInstallations()) // installations directly in each
.Select(i => new { i.Id, i.Name, i.Product })
.ToList<Object>();
}
[HttpGet(nameof(GetUsersWithInheritedAccessToInstallation))]
public ActionResult<IEnumerable<Object>> GetUsersWithInheritedAccessToInstallation(Int64 id, Token authToken)
{
@ -498,6 +657,20 @@ public class Controller : ControllerBase
.ToList();
}
[HttpGet(nameof(GetAllUsers))]
public ActionResult<IEnumerable<User>> GetAllUsers(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
if (user.UserType != 2) // admins only
return Unauthorized();
return Db.Users
.Select(u => u.HidePassword())
.ToList();
}
[HttpGet(nameof(GetAllInstallationsFromProduct))]
public ActionResult<IEnumerable<Installation>> GetAllInstallationsFromProduct(int product,Token authToken)
@ -718,13 +891,59 @@ public class Controller : ControllerBase
[HttpPut(nameof(UpdateUser))]
public ActionResult<User> UpdateUser([FromBody] User updatedUser, Token authToken)
public async Task<ActionResult<User>> UpdateUser([FromBody] User updatedUser, Token authToken)
{
var session = Db.GetSession(authToken);
var session = Db.GetSession(authToken);
var sessionUser = session?.User;
if (!session.Update(updatedUser))
// Normalize: trim surrounding whitespace so a stray space can't cause a login mismatch.
updatedUser.Email = updatedUser.Email?.Trim();
// Capture the original row BEFORE the update — needed to detect an email change
// and to roll back if the set-password email can't be delivered.
var original = Db.GetUserById(updatedUser.Id);
var emailChanged = original is not null
&& !String.Equals(original.Email, updatedUser.Email, StringComparison.OrdinalIgnoreCase);
// An email change re-registers the account (resets the password + sends mail), so it is
// ADMIN-ONLY. Checked BEFORE session.Update so a non-admin's email change is never written
// (partners may still edit name/role/information of their own users — that path is unchanged).
if (emailChanged && sessionUser?.UserType != 2) // 2 == admin
return Unauthorized();
// Reject a malformed new email before anything is written (defense alongside the frontend check).
if (emailChanged &&
!System.Text.RegularExpressions.Regex.IsMatch(updatedUser.Email ?? "", @"^[^\s@]+@[^\s@]+\.[^\s@]+$"))
return BadRequest("Invalid email format");
if (!session.Update(updatedUser)) // enforces permissions + email uniqueness; preserves parent/access
return Unauthorized();
if (emailChanged)
{
// Re-register: force the new address to set its own password via the welcome email.
// ACCEPTED LIMITATION: this is two DB writes (email via session.Update, then password
// here) plus an external email send, with no enclosing transaction (the codebase uses a
// single static connection and no transactions). A crash between the password clear and a
// successful send leaves the new email + an empty password (MustResetPassword=true). That
// state is recoverable by the admin re-applying the change, and the empty-password +
// MustResetPassword combination is the same posture as a freshly CreateUser'd account.
// We clear the password BEFORE sending (favouring the new owner's access) rather than
// after — sending first would, on a crash, lock the new owner out behind the old password.
Db.DeleteUserPassword(updatedUser); // Password="", MustResetPassword=true
var mailSuccess = await Db.SendNewUserEmail(updatedUser); // set-password mail -> NEW email
if (!mailSuccess)
{
// Full revert of the entire row to its pre-edit state — this also discards any
// Name/Role/Information edits made in the same Apply, by design (all-or-nothing).
Db.Update(original!);
return StatusCode(500, "Set-password email failed to send; email change reverted");
}
await Db.SendEmailChangedReminder(updatedUser, original!.Email); // reminder -> OLD email, best-effort
}
return updatedUser.HidePassword();
}
@ -3047,4 +3266,199 @@ public class Controller : ControllerBase
return Ok(summaries);
}
// ── Battery full-charge low-voltage anomaly (temporary diagnostic) ──
[HttpGet(nameof(GetBatteryVoltageAnomalySummary))]
public ActionResult<IEnumerable<Object>> GetBatteryVoltageAnomalySummary(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
// DetectedAt is "yyyy-MM-dd HH:mm:ss" — fixed-width, so lexicographic == chronological.
// If the format ever changes, this ordering breaks silently.
var summary = Db.BatteryVoltageAnomalies
.ToList()
.GroupBy(a => a.InstallationId)
.Select(g =>
{
var latest = g.OrderByDescending(a => a.DetectedAt).First();
return new
{
installationId = g.Key,
lastDetectedAt = latest.DetectedAt,
lastVoltage = latest.Voltage,
lastBattery = latest.BatteryLabel,
count = g.Count()
};
})
.ToList();
return Ok(summary);
}
[HttpGet(nameof(GetBatteryVoltageAnomalyLog))]
public ActionResult<IEnumerable<BatteryVoltageAnomaly>> GetBatteryVoltageAnomalyLog(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Ok(Db.GetBatteryVoltageAnomalies(installationId));
}
// ── On-Site Installer Checklist (sodistore home only) ──────────────
[HttpGet(nameof(GetOnSiteChecklistForInstallation))]
public ActionResult<Object> GetOnSiteChecklistForInstallation(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
// Sodistore home only (product == 2)
if (installation.Product != 2) return BadRequest("On-site checklist is only available for sodistore home installations.");
if (!Db.OnSiteChecklistExistsForInstallation(installationId))
{
foreach (var def in OnSiteChecklistStepDefinitions.Steps)
{
Db.Create(new OnSiteChecklistItem
{
InstallationId = installationId,
StepNumber = def.Number,
StepKey = def.StepKey,
Subtasks = def.SubtasksJson,
});
}
}
var items = Db.GetOnSiteChecklistForInstallation(installationId);
var signature = Db.GetOnSiteChecklistSignature(installationId);
if (signature is null)
{
signature = new OnSiteChecklistSignature { InstallationId = installationId };
Db.Create(signature);
}
return Ok(new { items, signature });
}
[HttpPut(nameof(UpdateOnSiteChecklistItem))]
public ActionResult<OnSiteChecklistItem> UpdateOnSiteChecklistItem(
Int64 onSiteChecklistItemId,
Boolean? @checked,
String? comments,
String? subtasks,
Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
var item = Db.GetOnSiteChecklistItemById(onSiteChecklistItemId);
if (item is null) return NotFound("On-site checklist item not found.");
var installation = Db.GetInstallationById(item.InstallationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
// Lock edits once signed (admins can still edit to support reset workflow).
var sig = Db.GetOnSiteChecklistSignature(item.InstallationId);
var isSigned = sig is not null && !String.IsNullOrEmpty(sig.SignedAt);
if (isSigned && user.UserType != 2) return BadRequest("Checklist is signed and locked.");
if (@checked.HasValue) item.Checked = @checked.Value;
if (comments is not null) item.Comments = comments;
if (subtasks is not null) item.Subtasks = subtasks;
item.UpdatedAt = DateTime.UtcNow;
return Db.Update(item) ? item : StatusCode(500, "Update failed.");
}
[HttpPut(nameof(UpdateOnSiteChecklistRemarks))]
public ActionResult<OnSiteChecklistSignature> UpdateOnSiteChecklistRemarks(
Int64 installationId,
String? remarks,
Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
// Skip writes when the param is omitted — prevents an empty/missing query
// string from silently wiping previously-saved remarks.
if (remarks is null) return BadRequest("Remarks parameter is required.");
var sig = Db.GetOnSiteChecklistSignature(installationId);
if (sig is null)
{
sig = new OnSiteChecklistSignature { InstallationId = installationId };
Db.Create(sig);
sig = Db.GetOnSiteChecklistSignature(installationId)!;
}
var isSigned = !String.IsNullOrEmpty(sig.SignedAt);
if (isSigned && user.UserType != 2) return BadRequest("Checklist is signed and locked.");
sig.Remarks = remarks;
sig.UpdatedAt = DateTime.UtcNow;
return Db.Update(sig) ? sig : StatusCode(500, "Update failed.");
}
[HttpPost(nameof(SignOnSiteChecklist))]
public ActionResult<OnSiteChecklistSignature> SignOnSiteChecklist(
Int64 installationId,
String signedByName,
Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
if (String.IsNullOrWhiteSpace(signedByName)) return BadRequest("Signed-by name is required.");
var sig = Db.GetOnSiteChecklistSignature(installationId);
if (sig is null)
{
sig = new OnSiteChecklistSignature { InstallationId = installationId };
Db.Create(sig);
sig = Db.GetOnSiteChecklistSignature(installationId)!;
}
sig.SignedByName = signedByName.Trim();
sig.SignedByUserId = user.Id;
sig.SignedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
sig.UpdatedAt = DateTime.UtcNow;
return Db.Update(sig) ? sig : StatusCode(500, "Sign failed.");
}
[HttpPost(nameof(ResetOnSiteChecklistSignature))]
public ActionResult<OnSiteChecklistSignature> ResetOnSiteChecklistSignature(
Int64 installationId,
Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
var sig = Db.GetOnSiteChecklistSignature(installationId);
if (sig is null) return NotFound("Signature row not found.");
sig.SignedByName = null;
sig.SignedByUserId = null;
sig.SignedAt = null;
sig.UpdatedAt = DateTime.UtcNow;
return Db.Update(sig) ? sig : StatusCode(500, "Reset failed.");
}
}

View File

@ -0,0 +1,19 @@
namespace InnovEnergy.App.Backend.DataTypes;
// Read-only DTO returned by GetAlarmReport. Not a database table.
// One row per raw alarm/warning occurrence, enriched with installation metadata
// so the frontend can group/filter across installations without extra round-trips.
public class AlarmReportRow
{
public Int64 Id { get; set; }
public Int64 InstallationId { get; set; }
public String InstallationName { get; set; } = "";
public int Product { get; set; }
public int Device { get; set; }
public String Severity { get; set; } = ""; // "error" | "warning"
public String Description { get; set; } = ""; // camelCase alarm code, e.g. "Battery2Undervoltage"
public String Date { get; set; } = ""; // "yyyy-MM-dd" (Swiss local time, as stamped by the device)
public String Time { get; set; } = ""; // "HH:mm:ss" (Swiss local time)
public String DeviceCreatedTheMessage { get; set; } = ""; // e.g. "Inverter 1"
public Boolean Seen { get; set; }
}

View File

@ -0,0 +1,30 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
// Temporary diagnostic: one row per battery cluster per hour where SOC=100% & pack voltage < 58V
// ("BMS fake-full"). When this feature is no longer needed, drop the table, this type,
// BatteryVoltageCheckService, BatteryVoltageAnomalyDetector, the two endpoints, the Db.cs
// CreateTable entries (main + fileConnection), and the related Create/Read/Delete helpers.
public class BatteryVoltageAnomaly
{
[PrimaryKey, AutoIncrement]
public Int64 Id { get; set; }
[Indexed]
public Int64 InstallationId { get; set; }
public String BatteryLabel { get; set; } = "";
public String DetectedAt { get; set; } = "";
[Indexed]
public String Date { get; set; } = "";
public String DateHour { get; set; } = "";
public Double Voltage { get; set; }
public Double Soc { get; set; }
public String CreatedAt { get; set; } = "";
}

View File

@ -13,7 +13,8 @@ public static class ChecklistStepDefinitions
new( 3, "Installation created on Monitor under correct product and folder",
"""
[
{"text":"checklistStep3Sub1","checked":false}
{"text":"checklistStep3Sub1","checked":false},
{"text":"checklistStep3Sub2","checked":false}
]
"""),
new( 4, "Gateway SD card configured, VPN and gateway name registered", NoSubtasks),
@ -64,8 +65,5 @@ public static class ChecklistStepDefinitions
new(11, "Software verified on site", NoSubtasks),
new(12, "Installation online on Monitor", NoSubtasks),
new(13, "Customer informed about Monitor account and reports", NoSubtasks),
new(14, "User account created with correct folders and access", NoSubtasks),
new(15, "Customer follow-up completed, feedback collected", NoSubtasks),
new(16, "Further issues tracked via Ticket system", NoSubtasks),
};
}

View File

@ -28,6 +28,13 @@ public class Configuration
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
public DateTime? StopTimeChargeandDischargeDayandTime { get; set; }
// SodistoreGrid: inverter battery-limit settings (Sinexcel) — surfaced on Configuration tab.
public double? ActivePowerPercent { get; set; }
public double? MinDischargeVoltage { get; set; }
public double? MaxDischargeCurrent { get; set; }
public double? MaxChargeCurrent { get; set; }
public double? MaxChargeVoltage { get; set; }
// Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later.
public string? DynamicPricingMode { get; set; }
public string? NetworkProvider { get; set; }
@ -40,6 +47,13 @@ public class Configuration
public string? TimeToBuyFrom { get; set; }
public string? TimeToBuyTo { get; set; }
// Relay control (Sinexcel only, device=4) — surfaced on Configuration tab for SodistoreHome + SodistorePro.
// Nullable so WhenWritingNull keeps them out of payloads for non-Sinexcel installations.
public bool? Relay1 { get; set; }
public bool? Relay2 { get; set; }
public bool? Relay3 { get; set; }
public bool? Relay4 { get; set; }
}
public enum CalibrationChargeType

View File

@ -61,7 +61,8 @@ public class Installation : TreeNode
public string PvStringsPerInverter { get; set; } = "";
public string InstallationModel { get; set; } = "";
public string ExternalEms { get; set; } = "No";
public string CouplingType { get; set; } = "DC";
public string? CouplingType { get; set; }
public string? BackupLoad { get; set; }
[Ignore]
public String OrderNumbers { get; set; }

View File

@ -367,12 +367,18 @@ public static class SessionMethods
{
var sessionUser = session?.User;
var originalUser = Db.GetUserById(editedUser?.Id);
return editedUser is not null
&& sessionUser is not null
&& originalUser is not null
&& sessionUser.UserType !=0
&& sessionUser.HasAccessTo(originalUser)
if (editedUser is null || sessionUser is null || originalUser is null)
return false;
// email must stay unique; pre-check to avoid hitting the DB [Unique] constraint (500)
var emailOwner = Db.GetUserByEmail(editedUser.Email);
return sessionUser.UserType != 0
&& originalUser.Id != 0 // belt: legacy root-id sentinel
&& originalUser.ParentId > 0 // never edit the main/root admin (parentless top user; ParentId<=0, Id NOT necessarily 0)
&& (sessionUser.UserType == 2 || sessionUser.HasAccessTo(originalUser)) // admins may edit any user
&& (emailOwner is null || emailOwner.Id == editedUser.Id) // email not taken by another user
&& editedUser
.WithParentOf(originalUser) // prevent moving
.WithPasswordOf(originalUser)
@ -397,7 +403,10 @@ public static class SessionMethods
return sessionUser is not null
&& userToDelete is not null
&& sessionUser.UserType !=0
&& sessionUser.HasAccessTo(userToDelete)
&& userToDelete.Id != 0 // belt: legacy root-id sentinel
&& userToDelete.ParentId > 0 // never delete the main/root admin (the parentless top user; ParentId<=0, Id NOT necessarily 0)
&& userToDelete.Id != sessionUser.Id // never self-delete (avoid lockout)
&& (sessionUser.UserType == 2 || sessionUser.HasAccessTo(userToDelete)) // admins may delete any user
&& Db.Delete(userToDelete);
}

View File

@ -298,6 +298,41 @@ public static class UserMethods
return user.SendEmail(subject, body);
}
public static Task SendEmailChangedNotice(this User user, String oldEmail)
{
var newEmail = user.Email;
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Die E-Mail-Adresse Ihres inesco energy Kontos wurde geändert",
$"Sehr geehrte/r {user.Name}\n" +
$"Die E-Mail-Adresse Ihres inesco energy Kontos wurde auf {newEmail} geändert. " +
$"Falls Sie diese Änderung nicht veranlasst haben, wenden Sie sich bitte an das inesco energy Support-Team."
),
"fr" => (
"L'adresse e-mail de votre compte inesco energy a été modifiée",
$"Cher/Chère {user.Name}\n" +
$"L'adresse e-mail de votre compte inesco energy a été remplacée par {newEmail}. " +
$"Si vous n'êtes pas à l'origine de cette modification, veuillez contacter l'équipe d'assistance inesco energy."
),
"it" => (
"L'indirizzo email del tuo account inesco energy è stato modificato",
$"Gentile {user.Name}\n" +
$"L'indirizzo email del tuo account inesco energy è stato cambiato in {newEmail}. " +
$"Se non hai richiesto questa modifica, contatta il team di supporto inesco energy."
),
_ => (
"Your inesco energy account email was changed",
$"Dear {user.Name}\n" +
$"The email address for your inesco energy account was changed to {newEmail}. " +
$"If you did not request this change, please contact the inesco energy Support Team."
)
};
return Mailer.Send(user.Name, oldEmail, subject, body);
}
public static Task SendTicketAssignedEmail(this User user, Ticket ticket)
{
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";

View File

@ -0,0 +1,22 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
// Installer-facing on-site installation checklist. One row per (installation, step).
// Distinct from ChecklistItem (internal monitor-onboarding workflow) — no assignee,
// no done-date; comments and substep state still persist per installation.
public class OnSiteChecklistItem
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 InstallationId { get; set; }
public Int32 StepNumber { get; set; }
public String StepKey { get; set; } = "";
public Boolean Checked { get; set; } = false;
public String Comments { get; set; } = "";
public String? Subtasks { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,21 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
// One row per installation. Holds the free-text installer remarks ("Bemerkungen vom
// Installateur") plus the e-signature (typed name + timestamp + signer user id).
// Existence is lazy: row is created on first read if absent.
public class OnSiteChecklistSignature
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 InstallationId { get; set; }
public String Remarks { get; set; } = "";
public String? SignedByName { get; set; }
public Int64? SignedByUserId{ get; set; }
public String? SignedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,98 @@
namespace InnovEnergy.App.Backend.DataTypes;
public record OnSiteChecklistStepDefinition(Int32 Number, String StepKey, String? SubtasksJson);
// Static definitions for the installer-facing on-site checklist (sodistore home only).
// Step titles, warning text, and image filenames live in the frontend (translations +
// static assets) and are keyed by Number — the backend only stores per-step state.
// Subtask JSON items can be either a checkable substep `{"text":"key","checked":false}`
// or a non-checkable sub-heading `{"heading":"key"}` (e.g. "Notstromanforderungen").
public static class OnSiteChecklistStepDefinitions
{
public static readonly IReadOnlyList<OnSiteChecklistStepDefinition> Steps = new List<OnSiteChecklistStepDefinition>
{
new(1, "auspacken",
"""
[
{"text":"onSiteStep1Sub1","checked":false},
{"text":"onSiteStep1Sub2","checked":false}
]
"""),
new(2, "wechselrichterMontieren",
"""
[
{"text":"onSiteStep2Sub1","checked":false},
{"text":"onSiteStep2Sub2","checked":false},
{"text":"onSiteStep2Sub3","checked":false}
]
"""),
new(3, "hauptverteilungAcSeitig",
"""
[
{"text":"onSiteStep3Sub1","checked":false},
{"text":"onSiteStep3Sub2","checked":false},
{"text":"onSiteStep3Sub3","checked":false},
{"text":"onSiteStep3Sub4","checked":false},
{"text":"onSiteStep3Sub5","checked":false},
{"text":"onSiteStep3Sub6","checked":false},
{"heading":"onSiteStep3Subheading1"},
{"text":"onSiteStep3Sub7","checked":false},
{"text":"onSiteStep3Sub8","checked":false},
{"text":"onSiteStep3Sub9","checked":false},
{"text":"onSiteStep3Sub10","checked":false}
]
"""),
new(4, "meterAnschliessen",
"""
[
{"text":"onSiteStep4Sub1","checked":false},
{"text":"onSiteStep4Sub2","checked":false},
{"text":"onSiteStep4Sub3","checked":false},
{"text":"onSiteStep4Sub4","checked":false}
]
"""),
new(5, "gatewayInternet",
"""
[
{"text":"onSiteStep5Sub1","checked":false},
{"text":"onSiteStep5Sub2","checked":false},
{"text":"onSiteStep5Sub3","checked":false},
{"text":"onSiteStep5Sub4","checked":false}
]
"""),
new(6, "systemverbindungen",
"""
[
{"text":"onSiteStep6Sub1","checked":false},
{"text":"onSiteStep6Sub2","checked":false},
{"text":"onSiteStep6Sub3","checked":false},
{"text":"onSiteStep6Sub4","checked":false},
{"text":"onSiteStep6Sub5","checked":false},
{"text":"onSiteStep6Sub6","checked":false},
{"text":"onSiteStep6Sub7","checked":false}
]
"""),
new(7, "pvWechselrichter",
"""
[
{"text":"onSiteStep7Sub1","checked":false}
]
"""),
new(8, "inbetriebnahme",
"""
[
{"text":"onSiteStep8Sub1","checked":false},
{"text":"onSiteStep8Sub2","checked":false},
{"text":"onSiteStep8Sub3","checked":false},
{"text":"onSiteStep8Sub4","checked":false},
{"text":"onSiteStep8Sub5","checked":false},
{"text":"onSiteStep8Sub6","checked":false},
{"text":"onSiteStep8Sub7","checked":false},
{"text":"onSiteStep8Sub8","checked":false},
{"text":"onSiteStep8Sub9","checked":false},
{"text":"onSiteStep8Sub10","checked":false},
{"text":"onSiteStep8Sub11","checked":false}
]
"""),
};
}

View File

@ -47,7 +47,12 @@ public static partial class Db
{
return Insert(session);
}
public static Boolean Create(BatteryVoltageAnomaly anomaly)
{
return Insert(anomaly);
}
public static Boolean Create(InstallationAccess installationAccess)
{
return Insert(installationAccess);
@ -94,7 +99,11 @@ public static partial class Db
// Checklist
public static Boolean Create(ChecklistItem item) => Insert(item);
// On-site installer checklist
public static Boolean Create(OnSiteChecklistItem item) => Insert(item);
public static Boolean Create(OnSiteChecklistSignature sig) => Insert(sig);
public static void HandleAction(UserAction newAction)
{
//Find the total number of actions for this installation

View File

@ -46,6 +46,13 @@ public static partial class Db
// Checklist
public static TableQuery<ChecklistItem> ChecklistItems => Connection.Table<ChecklistItem>();
// On-site installer checklist (sodistore home)
public static TableQuery<OnSiteChecklistItem> OnSiteChecklistItems => Connection.Table<OnSiteChecklistItem>();
public static TableQuery<OnSiteChecklistSignature> OnSiteChecklistSignatures => Connection.Table<OnSiteChecklistSignature>();
// Battery full-charge low-voltage anomaly log (temporary diagnostic)
public static TableQuery<BatteryVoltageAnomaly> BatteryVoltageAnomalies => Connection.Table<BatteryVoltageAnomaly>();
public static void Init()
{
@ -90,6 +97,13 @@ public static partial class Db
// Checklist
Connection.CreateTable<ChecklistItem>();
// On-site installer checklist (sodistore home)
Connection.CreateTable<OnSiteChecklistItem>();
Connection.CreateTable<OnSiteChecklistSignature>();
// Battery full-charge low-voltage anomaly log (temporary diagnostic)
Connection.CreateTable<BatteryVoltageAnomaly>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
@ -160,6 +174,18 @@ public static partial class Db
// raw-string JSON form preserving each row's existing checked state.
BackfillStep10Sub3();
// One-time backfill: the former step 14 (user account created) became a subtask of step 3.
// Append it to existing step-3 rows, preserving each row's existing checked state.
BackfillStep3Sub2();
// One-time cleanup: steps 14 (now a subtask of step 3), 15 and 16 were removed from the
// onboarding checklist. Drop any rows seeded before removal.
Connection.Execute("DELETE FROM ChecklistItem WHERE StepNumber IN (14, 15, 16)");
// One-time cleanup: step 9 (Abschluss) was removed from the on-site checklist —
// the e-signature block now handles sign-off. Drop any rows seeded before removal.
Connection.Execute("DELETE FROM OnSiteChecklistItem WHERE StepNumber = 9");
//UpdateKeys();
CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning();
@ -188,6 +214,31 @@ public static partial class Db
}
}
// Must run AFTER the step-3 Sub1 SQL backfill above: that step populates rows whose Subtasks
// were NULL/empty, after which they become eligible for the Sub2 append below.
private static void BackfillStep3Sub2()
{
var rows = Connection.Query<ChecklistItem>(
"SELECT * FROM ChecklistItem WHERE StepNumber = 3 AND Subtasks IS NOT NULL AND Subtasks != ''");
foreach (var row in rows)
{
if (row.Subtasks is null) continue;
if (row.Subtasks.Contains("checklistStep3Sub2")) continue;
// Insert sub2 before the closing bracket. Works regardless of indentation/whitespace
// since we only look for the last `]`.
var lastBracket = row.Subtasks.LastIndexOf(']');
if (lastBracket < 0) continue;
var head = row.Subtasks.Substring(0, lastBracket).TrimEnd();
// head ends with `}` of the last existing subtask.
var newJson = head + ",{\"text\":\"checklistStep3Sub2\",\"checked\":false}]";
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE Id = ?",
newJson, row.Id);
}
}
private static SQLiteConnection InitConnection()
{
var latestDb = new DirectoryInfo("DbBackups")
@ -232,6 +283,13 @@ public static partial class Db
// Checklist
fileConnection.CreateTable<ChecklistItem>();
// On-site installer checklist (sodistore home)
fileConnection.CreateTable<OnSiteChecklistItem>();
fileConnection.CreateTable<OnSiteChecklistSignature>();
// Battery full-charge low-voltage anomaly log (temporary diagnostic)
fileConnection.CreateTable<BatteryVoltageAnomaly>();
// Migrate new columns: set defaults for existing rows where NULL or empty
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");
@ -458,6 +516,21 @@ public static partial class Db
}
}
public static async Task<Boolean> SendEmailChangedReminder(User user, String oldEmail)
{
try
{
await user.SendEmailChangedNotice(oldEmail);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Email-changed reminder failed for {oldEmail}");
Console.WriteLine(ex.ToString());
return false;
}
}
public static Boolean DeleteUserPassword(User user)
{
user.Password = "";

View File

@ -164,8 +164,18 @@ public static partial class Db
Boolean DeleteUserAndHisDependencies()
{
// Re-parent the deleted user's children up to its own parent so no subtree is orphaned
// (a dangling ParentId would make the children invisible/unmanageable in the tree).
var children = Users.Where(u => u.ParentId == user.Id).ToList();
foreach (var child in children)
{
child.ParentId = user.ParentId;
Connection.Update(child);
}
FolderAccess .Delete(u => u.UserId == user.Id);
InstallationAccess.Delete(u => u.UserId == user.Id);
Sessions .Delete(s => s.UserId == user.Id); // kill the deleted user's login sessions immediately
return Users.Delete(u => u.Id == user.Id) > 0;
}
}
@ -365,4 +375,9 @@ public static partial class Db
Backup();
Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff}).");
}
public static void DeleteBatteryVoltageAnomaliesBefore(String isoDate)
{
Connection.Execute("DELETE FROM BatteryVoltageAnomaly WHERE Date < ?", isoDate);
}
}

View File

@ -255,4 +255,36 @@ public static partial class Db
public static ChecklistItem? GetChecklistItemById(Int64 id)
=> ChecklistItems.FirstOrDefault(c => c.Id == id);
// ── On-Site Checklist Queries ────────────────────────────────────────
public static List<OnSiteChecklistItem> GetOnSiteChecklistForInstallation(Int64 installationId)
=> OnSiteChecklistItems
.Where(c => c.InstallationId == installationId)
.OrderBy(c => c.StepNumber)
.ToList();
public static Boolean OnSiteChecklistExistsForInstallation(Int64 installationId)
=> OnSiteChecklistItems.Any(c => c.InstallationId == installationId);
public static OnSiteChecklistItem? GetOnSiteChecklistItemById(Int64 id)
=> OnSiteChecklistItems.FirstOrDefault(c => c.Id == id);
public static OnSiteChecklistSignature? GetOnSiteChecklistSignature(Int64 installationId)
=> OnSiteChecklistSignatures.FirstOrDefault(s => s.InstallationId == installationId);
// ── BatteryVoltageAnomaly Queries (temporary diagnostic) ────────────
public static Boolean BatteryVoltageAnomalyExists(Int64 installationId, String batteryLabel, String dateHour)
=> BatteryVoltageAnomalies.Any(a =>
a.InstallationId == installationId &&
a.BatteryLabel == batteryLabel &&
a.DateHour == dateHour);
public static List<BatteryVoltageAnomaly> GetBatteryVoltageAnomalies(Int64 installationId)
=> BatteryVoltageAnomalies
.Where(a => a.InstallationId == installationId)
.ToList()
.OrderByDescending(a => a.DetectedAt)
.ToList();
}

View File

@ -76,4 +76,8 @@ public static partial class Db
// Checklist
public static Boolean Update(ChecklistItem item) => Update(obj: item);
// On-site installer checklist
public static Boolean Update(OnSiteChecklistItem item) => Update(obj: item);
public static Boolean Update(OnSiteChecklistSignature sig) => Update(obj: sig);
}

View File

@ -54,7 +54,7 @@ public static class DeleteOldDataFromS3
var bucket = s3Region.Bucket(installation.BucketName());
Console.WriteLine($"[S3Cleanup] Processing {installation.Name} (bucket: {bucket.Name})");
var deleted = await DeleteObjectsBefore(bucket, cutoffKey);
var deleted = await DeleteObjectsBefore(bucket, cutoffTimestamp);
Console.WriteLine($"[S3Cleanup] {installation.Name}: deleted {deleted} objects");
}
catch (Exception ex)
@ -95,8 +95,11 @@ public static class DeleteOldDataFromS3
await foreach (var obj in bucket.ListObjects())
{
if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0)
break;
if (!TryGetChunkTimestamp(obj.Path, out var ts))
continue; // skip aggregated DDMMYYYY.json and any non-chunk object
if (ts >= cutoffTimestamp)
break; // chunks are listed in ascending order; everything after is newer
hasOldData = true;
if (sampleKeys.Count < 5)
@ -121,15 +124,33 @@ public static class DeleteOldDataFromS3
return string.Join("\n", results);
}
private static async Task<int> DeleteObjectsBefore(S3Bucket bucket, string cutoffKey)
// Raw 10-second data chunks are named "{unixSeconds}.json"/".csv" (10-digit timestamps).
// Device-generated aggregated files are named "DDMMYYYY.json" (max 8 digits -> < 1e9).
// Only timestamp chunks may be deleted; the threshold cleanly separates the two formats and
// avoids deleting aggregated daily files by lexicographic accident.
private const Int64 MinUnixTimestamp = 1_000_000_000;
private static Boolean TryGetChunkTimestamp(String key, out Int64 timestamp)
{
timestamp = 0;
var file = key.Substring(key.LastIndexOf('/') + 1);
var dot = file.IndexOf('.');
var stem = dot >= 0 ? file.Substring(0, dot) : file;
return Int64.TryParse(stem, out timestamp) && timestamp >= MinUnixTimestamp;
}
private static async Task<int> DeleteObjectsBefore(S3Bucket bucket, long cutoffTimestamp)
{
var totalDeleted = 0;
var keysToDelete = new List<string>();
await foreach (var obj in bucket.ListObjects())
{
if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0)
break;
if (!TryGetChunkTimestamp(obj.Path, out var ts))
continue; // skip aggregated DDMMYYYY.json and any non-chunk object
if (ts >= cutoffTimestamp)
break; // chunks are listed in ascending order; everything after is newer
keysToDelete.Add(obj.Path);

View File

@ -36,6 +36,7 @@ public static class Program
AlarmReviewService.StartDailyScheduler();
DailyIngestionService.StartScheduler();
ReportAggregationService.StartScheduler();
BatteryVoltageCheckService.StartScheduler();
var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment();

View File

@ -0,0 +1,113 @@
using System.Text;
using System.Text.Json;
namespace InnovEnergy.App.Backend.Services;
public readonly record struct VoltageAnomaly(String BatteryLabel, Double Soc, Double Voltage, DateTime? Timestamp);
public static class BatteryVoltageAnomalyDetector
{
private const Double FullSoc = 100.0;
private const Double LowVoltageVolt = 58.0;
private const Int32 MaxClusters = 8;
public static List<VoltageAnomaly> Detect(String liveJson, Int32 device)
{
var decoded = Decode(liveJson);
using var doc = JsonDocument.Parse(decoded);
var root = Unwrap(doc.RootElement);
var ts = ReadTimestamp(root);
return device switch
{
3 => DetectGrowatt(root, ts),
4 => DetectSinexcel(root, ts),
_ => new List<VoltageAnomaly>(),
};
}
private static List<VoltageAnomaly> DetectSinexcel(JsonElement root, DateTime? ts)
{
var found = new List<VoltageAnomaly>();
if (!root.TryGetProperty("Devices", out var devices) || devices.ValueKind != JsonValueKind.Object)
{
Console.WriteLine("[BattVoltage] Sinexcel JSON has no Devices node — check live-file shape assumption.");
return found;
}
foreach (var dev in devices.EnumerateObject())
{
for (var cl = 1; cl <= MaxClusters; cl++)
{
// Battery1 uses "SocSecondvalue" (capital S), Battery2 uses "Socsecondvalue"
// (lowercase s) per the documented live-data schema; try both casings.
var soc = ReadDouble(dev.Value, $"Battery{cl}Soc")
?? ReadDouble(dev.Value, $"Battery{cl}SocSecondvalue")
?? ReadDouble(dev.Value, $"Battery{cl}Socsecondvalue");
var v = ReadDouble(dev.Value, $"Battery{cl}PackTotalVoltage");
if (soc is null || v is null) continue;
if (IsAnomaly(soc.Value, v.Value))
found.Add(new VoltageAnomaly($"Inv{dev.Name}/Bat{cl}", soc.Value, v.Value, ts));
}
}
return found;
}
private static List<VoltageAnomaly> DetectGrowatt(JsonElement root, DateTime? ts)
{
var found = new List<VoltageAnomaly>();
for (var i = 1; i <= MaxClusters; i++)
{
var soc = ReadDouble(root, $"Battery{i}Soc");
var v = ReadDouble(root, $"Battery{i}Voltage");
if (soc is null || v is null) continue;
if (IsAnomaly(soc.Value, v.Value))
found.Add(new VoltageAnomaly($"Bat{i}", soc.Value, v.Value, ts));
}
return found;
}
private static Boolean IsAnomaly(Double soc, Double v) => soc >= FullSoc && v < LowVoltageVolt;
private static Double? ReadDouble(JsonElement e, String name)
{
if (!e.TryGetProperty(name, out var p)) return null;
if (p.ValueKind == JsonValueKind.Number && p.TryGetDouble(out var d)) return d;
if (p.ValueKind == JsonValueKind.String &&
Double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var ds)) return ds;
return null;
}
private static DateTime? ReadTimestamp(JsonElement e)
{
if (!e.TryGetProperty("Timestamp", out var p)) return null;
if (p.ValueKind == JsonValueKind.String && p.TryGetDateTime(out var dt))
{
// Unspecified-Kind (no offset in source) is assumed UTC; otherwise convert.
// Without this, downstream ToString("yyyy-MM-dd HH") prints local-time for
// some sources and UTC for others, breaking the (..., DateHour) dedup key
// and the cleanup-cutoff comparison.
return dt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: dt.ToUniversalTime();
}
if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var unix))
return DateTimeOffset.FromUnixTimeSeconds(unix).UtcDateTime;
return null;
}
private static JsonElement Unwrap(JsonElement root)
=> root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("InverterRecord", out var inner)
? inner
: root;
private static String Decode(String raw)
{
var t = raw.Trim();
if (t.StartsWith('{') || t.StartsWith('[')) return raw;
try { return Encoding.UTF8.GetString(Convert.FromBase64String(t)); }
catch { return raw; }
}
}

View File

@ -0,0 +1,144 @@
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
namespace InnovEnergy.App.Backend.Services;
public static class BatteryVoltageCheckService
{
private static Timer? _timer;
private const Int32 WindowStartUtc = 8;
private const Int32 WindowEndUtc = 17;
private const Int32 RetentionDays = 90;
private const Int64 MinUnixTs = 1_000_000_000L;
public static void StartScheduler()
{
_timer = new Timer(_ =>
{
try { RunOnce().GetAwaiter().GetResult(); }
catch (Exception ex) { Console.Error.WriteLine($"[BattVoltage] Scheduler error: {ex.Message}"); }
}, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(30));
Console.WriteLine($"[BattVoltage] Scheduler started (every 30 min, UTC {WindowStartUtc:00}:00{WindowEndUtc:00}:00).");
}
private static async Task RunOnce()
{
// Retention runs regardless of the midday window — otherwise a backend whose
// uptime never overlaps 0817 UTC (frequent evening restarts) never prunes.
CleanupOld();
var hour = DateTime.UtcNow.Hour;
if (hour < WindowStartUtc || hour >= WindowEndUtc) return;
var installations = Db.Installations
.Where(i => (i.Product == (Int32)ProductType.SodioHome ||
i.Product == (Int32)ProductType.SodistorePro)
&& i.DataCollectionEnabled)
.ToList();
var total = 0;
foreach (var inst in installations)
{
try { total += await CheckInstallation(inst); }
catch (Exception ex) { Console.Error.WriteLine($"[BattVoltage] Inst {inst.Id} failed: {ex.Message}"); }
}
if (total > 0) Console.WriteLine($"[BattVoltage] Run complete: {total} new anomaly row(s).");
}
private static async Task<Int32> CheckInstallation(Installation inst)
{
var liveJson = await TryReadNewestLiveFile(inst);
if (liveJson is null) return 0;
List<VoltageAnomaly> anomalies;
try { anomalies = BatteryVoltageAnomalyDetector.Detect(liveJson, inst.Device); }
catch (Exception ex)
{
Console.Error.WriteLine($"[BattVoltage] Inst {inst.Id} detect failed: {ex.Message}");
return 0;
}
var inserted = 0;
foreach (var a in anomalies)
{
var detected = a.Timestamp ?? DateTime.UtcNow;
var dateHour = detected.ToString("yyyy-MM-dd HH");
if (Db.BatteryVoltageAnomalyExists(inst.Id, a.BatteryLabel, dateHour)) continue;
Db.Create(new BatteryVoltageAnomaly
{
InstallationId = inst.Id,
BatteryLabel = a.BatteryLabel,
DetectedAt = detected.ToString("yyyy-MM-dd HH:mm:ss"),
Date = detected.ToString("yyyy-MM-dd"),
DateHour = dateHour,
Voltage = Math.Round(a.Voltage, 2),
Soc = a.Soc,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
inserted++;
Console.WriteLine($"[BattVoltage] Inst {inst.Id} {a.BatteryLabel}: SOC {a.Soc} @ {a.Voltage:F2}V");
}
return inserted;
}
private static async Task<String?> TryReadNewestLiveFile(Installation inst)
{
try
{
var region = new S3Region($"https://{inst.S3Region}.{inst.S3Provider}", ExoCmd.S3Credentials!);
var bucket = region.Bucket(inst.BucketName());
// Prefix-scope to recent files: first 5 digits of current unix-ts covers
// a ~27h window (vs a full-bucket scan of potentially 100k+ objects on
// 10-second cadence). At a digit boundary we may briefly miss until next
// poll — acceptable for a 30-min temporary diagnostic.
var nowTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var prefix = (nowTs / 100_000L).ToString();
S3Url? newest = null;
var newestTs = Int64.MinValue;
await foreach (var url in bucket.ListObjects(prefix))
{
if (!TryParseUnixTs(url.Path, out var ts)) continue;
if (ts <= newestTs) continue;
newestTs = ts;
newest = url;
}
if (newest is null) return null;
return await newest.GetObjectAsString();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BattVoltage] Inst {inst.Id} S3 read failed: {ex.Message}");
return null;
}
}
private static Boolean TryParseUnixTs(String path, out Int64 ts)
{
ts = 0;
var slash = path.LastIndexOf('/');
var name = slash >= 0 ? path[(slash + 1)..] : path;
var dot = name.LastIndexOf('.');
var stem = dot > 0 ? name[..dot] : name;
if (!Int64.TryParse(stem, out var v)) return false;
if (v < MinUnixTs) return false;
ts = v;
return true;
}
private static void CleanupOld()
{
var cutoff = DateTime.UtcNow.AddDays(-RetentionDays).ToString("yyyy-MM-dd");
Db.DeleteBatteryVoltageAnomaliesBefore(cutoff);
}
}

View File

@ -0,0 +1,260 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.IO.Compression;
using System.Text.Json;
using System.Text.RegularExpressions;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.S3Utils;
using S3Bucket = InnovEnergy.Lib.S3Utils.DataTypes.S3Bucket;
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// One data point of the dynamic-pricing "Current Price" history (CHF/kWh).
/// </summary>
public class PricePoint
{
public Int64 Timestamp { get; set; } // unix seconds
public Double Price { get; set; } // CHF/kWh
}
/// <summary>
/// Builds the "Current Price" history shown on the Configuration tab for
/// Growatt (device 3) and Sinexcel (device 4) installations.
///
/// CurrentPrice only lives inside the per-10-second chunk files (Config.CurrentPrice);
/// there is no pre-aggregated source. To keep load bounded we sample ONE chunk per
/// 15-minute slot, fetch those in parallel, and cache each fully-past day (immutable).
/// </summary>
public static class CurrentPriceHistoryService
{
private const Int64 BucketSeconds = 900; // 15-minute resolution
private const Int32 MaxParallelFetches = 24;
private const Int32 CacheRetentionDays = 14; // bound the in-memory day cache
private const String S3CfgPath = "/home/ubuntu/.s3cfg";
private static readonly Regex ChunkFileRegex = new(@"/([0-9]+)\.(csv|json)$", RegexOptions.Compiled);
// Immutable past days cached as "{installationId}:{yyyyMMdd}".
private static readonly ConcurrentDictionary<String, List<PricePoint>> DayCache = new();
public static async Task<List<PricePoint>> GetHistory(Installation installation, Int64 startSec, Int64 endSec)
{
var todayUtc = DateTime.UtcNow.Date;
var firstDay = DateTimeOffset.FromUnixTimeSeconds(startSec).UtcDateTime.Date;
var lastDay = DateTimeOffset.FromUnixTimeSeconds(endSec).UtcDateTime.Date;
var points = new List<PricePoint>();
for (var day = firstDay; day <= lastDay; day = day.AddDays(1))
points.AddRange(await GetDay(installation, day, cacheable: day < todayUtc));
return points
.Where(p => p.Timestamp >= startSec && p.Timestamp <= endSec)
.OrderBy(p => p.Timestamp)
.ToList();
}
private static async Task<List<PricePoint>> GetDay(Installation installation, DateTime dayUtc, Boolean cacheable)
{
var key = $"{installation.Id}:{dayUtc:yyyyMMdd}";
if (cacheable && DayCache.TryGetValue(key, out var cached))
return cached;
var dayStart = new DateTimeOffset(dayUtc, TimeSpan.Zero).ToUnixTimeSeconds();
var dayEnd = dayStart + 86400 - 1;
var timestamps = SampleByBucket(ListChunkTimestamps(installation, dayStart, dayEnd));
var points = await FetchPrices(installation, timestamps);
// Only cache non-empty past days: an empty result can mean a transient s3cmd/S3
// failure, and caching that would serve "no data" forever until restart.
if (cacheable && points.Count > 0)
CacheDay(key, points);
return points;
}
private static void CacheDay(String key, List<PricePoint> points)
{
DayCache[key] = points;
// Prune entries older than the retention window to bound memory growth.
var cutoff = DateTime.UtcNow.Date.AddDays(-CacheRetentionDays);
foreach (var existingKey in DayCache.Keys)
{
var datePart = existingKey.Substring(existingKey.IndexOf(':') + 1);
if (DateTime.TryParseExact(datePart, "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var day) && day < cutoff)
DayCache.TryRemove(existingKey, out _);
}
}
// Keep the first chunk in each 15-minute slot.
private static List<Int64> SampleByBucket(List<Int64> timestamps)
{
var seenBuckets = new HashSet<Int64>();
var picked = new List<Int64>();
foreach (var t in timestamps.OrderBy(x => x))
if (seenBuckets.Add(t / BucketSeconds))
picked.Add(t);
return picked;
}
// List every chunk filename in range via `s3cmd ls` over the 5-digit timestamp prefixes
// (same listing approach as Controller.GetCsvTimestampsForInstallation).
private static List<Int64> ListChunkTimestamps(Installation installation, Int64 start, Int64 end)
{
var all = new List<Int64>();
var startPrefix = Int64.Parse(start.ToString().Substring(0, 5));
var endPrefix = Int64.Parse(end.ToString().Substring(0, 5));
for (var prefix = startPrefix; prefix <= endPrefix; prefix++)
{
var output = RunS3cmdLs("s3://" + installation.BucketName() + "/" + prefix);
foreach (var line in output.Split('\n'))
{
var match = ChunkFileRegex.Match(line);
if (match.Success && Int64.TryParse(match.Groups[1].Value, out var t) && t >= start && t <= end)
all.Add(t);
}
}
return all;
}
private static String RunS3cmdLs(String bucketPath)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "s3cmd",
Arguments = $"--config {S3CfgPath} ls {bucketPath}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.StandardError.ReadToEnd();
process.WaitForExit();
return process.ExitCode == 0 ? output : "";
}
catch (Exception e)
{
Console.WriteLine($"[CurrentPriceHistory] s3cmd ls failed for {bucketPath}: {e.Message}");
return "";
}
}
private static async Task<List<PricePoint>> FetchPrices(Installation installation, List<Int64> timestamps)
{
var region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", ExoCmd.S3Credentials!);
var bucket = region.Bucket(installation.BucketName());
using var gate = new SemaphoreSlim(MaxParallelFetches);
var tasks = timestamps.Select(async ts =>
{
await gate.WaitAsync();
try
{
var price = await FetchPriceAt(bucket, ts);
return price.HasValue ? new PricePoint { Timestamp = ts, Price = price.Value } : null;
}
finally
{
gate.Release();
}
});
var results = await Task.WhenAll(tasks);
return results.Where(p => p != null).Select(p => p!).OrderBy(p => p.Timestamp).ToList();
}
private static async Task<Double?> FetchPriceAt(S3Bucket bucket, Int64 ts)
{
try
{
var raw = await bucket.Path($"{ts}.json").GetObjectAsString();
var json = DecodeChunk(raw);
return json == null ? null : ExtractCurrentPrice(json);
}
catch
{
return null; // missing chunk / decode error -> just skip this slot
}
}
// Chunk objects are Base64-encoded ZIP archives whose inner "data.json" holds the record.
private static String? DecodeChunk(String raw)
{
try
{
var trimmed = raw.Trim();
if (trimmed.StartsWith('{'))
return raw; // defensive: already plain JSON
var bytes = Convert.FromBase64String(trimmed);
using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read);
var entry = zip.GetEntry("data.json");
if (entry == null)
return null;
using var reader = new StreamReader(entry.Open());
return reader.ReadToEnd();
}
catch
{
return null;
}
}
// data.json is a line-based dump (same format the frontend parseChunkJson consumes):
// Timestamp;<unix>;
// {"Config":{"CurrentPrice":0.19,...},"InverterRecord":{...}}
// i.e. a header line then a JSON-record line, possibly repeated. It is NOT a single
// JSON object, so parse line by line and return CurrentPrice from the last record.
private static Double? ExtractCurrentPrice(String dataJson)
{
Double? last = null;
foreach (var line in dataJson.Split('\n'))
{
var price = TryReadLinePrice(line.Trim());
if (price.HasValue)
last = price;
}
return last;
}
private static Double? TryReadLinePrice(String line)
{
if (line.Length == 0 || line[0] != '{')
return null; // skip "Timestamp;...;" headers and blank lines
try
{
using var doc = JsonDocument.Parse(line);
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("Config", out var config)
|| !config.TryGetProperty("CurrentPrice", out var currentPrice))
return null;
return currentPrice.ValueKind switch
{
JsonValueKind.Number => currentPrice.GetDouble(),
JsonValueKind.String when Double.TryParse(currentPrice.GetString(),
NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => null
};
}
catch
{
return null; // skip a malformed record line
}
}
}

View File

@ -188,21 +188,22 @@ public static class RabbitMqManager
Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status);
const int AlarmStatus = 2;
var isSodistore = installation.Product is 2 or 3 or 4 or 5;
if (isSodistore
&& prevStatus != AlarmStatus
&& receivedStatusMessage.Status == AlarmStatus)
{
var prev = prevStatus;
var alarmsSnapshot = (IReadOnlyList<AlarmOrWarning>)
(receivedStatusMessage.Alarms?.ToList() ?? new List<AlarmOrWarning>());
_ = Task.Run(async () =>
{
try { await AutoTicketService.MaybeCreateForAlarmAsync(installation, prev, alarmsSnapshot); }
catch (Exception ex) { Console.WriteLine($"[AutoTicket] alarm failed for {installationId}: {ex.Message}"); }
});
}
// Automatic ticket creation on error/warning (alarm) state — disabled.
// const int AlarmStatus = 2;
// var isSodistore = installation.Product is 2 or 3 or 4 or 5;
// if (isSodistore
// && prevStatus != AlarmStatus
// && receivedStatusMessage.Status == AlarmStatus)
// {
// var prev = prevStatus;
// var alarmsSnapshot = (IReadOnlyList<AlarmOrWarning>)
// (receivedStatusMessage.Alarms?.ToList() ?? new List<AlarmOrWarning>());
// _ = Task.Run(async () =>
// {
// try { await AutoTicketService.MaybeCreateForAlarmAsync(installation, prev, alarmsSnapshot); }
// catch (Exception ex) { Console.WriteLine($"[AutoTicket] alarm failed for {installationId}: {ex.Message}"); }
// });
// }
//Console.WriteLine("----------------------------------------------");
//If the status has changed, update all the connected front-ends regarding this installation

View File

@ -119,6 +119,7 @@ function App() {
const Login = Loader(lazy(() => import('src/components/login')));
const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
const Report = Loader(lazy(() => import('src/content/dashboards/Report')));
useEffect(() => {
if (!username || token) return;
@ -273,6 +274,7 @@ function App() {
/>
<Route path={routes.tickets + '*'} element={<Tickets />} />
<Route path={routes.alarmReport + '*'} element={<Report />} />
<Route path={routes.users + '*'} element={<Users />} />
<Route
path={'*'}

View File

@ -27,5 +27,7 @@
"installationTickets": "installationTickets",
"documents": "documents",
"checklist": "checklist",
"tickets": "/tickets/"
"onSiteChecklist": "onsiteChecklist",
"tickets": "/tickets/",
"alarmReport": "/alarm_report/"
}

View File

@ -0,0 +1,32 @@
// Static image imports for the sodistore home on-site installation checklist.
// Webpack/CRA resolves each import to a bundled URL string.
import step1Auspacken from './step1-auspacken.jpg';
import step2WrMontiert from './step2-wr-montiert.jpg';
import step2AcAnschluesse from './step2-ac-anschluesse.jpg';
import step2AcStecker from './step2-ac-stecker.jpg';
import step3Hauptverteilung from './step3-hauptverteilung.jpg';
import step3Hauptverteilung2 from './step3-hauptverteilung-2.jpg';
import step4Meter from './step4-meter.jpg';
import step4Wandler from './step4-wandler.jpg';
import step5Gateway from './step5-gateway.jpg';
import step6Batterien from './step6-batterien.jpg';
import step6Splitter from './step6-splitter.jpg';
import step7PvSwitch from './step7-pv-switch.jpg';
import step8BatterieBreaker from './step8-batterie-breaker.jpg';
export const ON_SITE_IMAGES: Readonly<Record<string, string>> = {
'step1-auspacken.jpg': step1Auspacken,
'step2-wr-montiert.jpg': step2WrMontiert,
'step2-ac-anschluesse.jpg': step2AcAnschluesse,
'step2-ac-stecker.jpg': step2AcStecker,
'step3-hauptverteilung.jpg': step3Hauptverteilung,
'step3-hauptverteilung-2.jpg': step3Hauptverteilung2,
'step4-meter.jpg': step4Meter,
'step4-wandler.jpg': step4Wandler,
'step5-gateway.jpg': step5Gateway,
'step6-batterien.jpg': step6Batterien,
'step6-splitter.jpg': step6Splitter,
'step7-pv-switch.jpg': step7PvSwitch,
'step8-batterie-breaker.jpg': step8BatterieBreaker
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

View File

@ -45,14 +45,36 @@ function BatteryView(props: BatteryViewProps) {
const navigate = useNavigate();
const { product, setProduct } = useContext(ProductIdContext);
const sortedBatteryView =
props.values != null && props.values?.Battery?.Devices
? Object.entries(props.values.Battery.Devices)
.map(([BatteryId, battery]) => {
return { BatteryId, battery }; // Here we return an object with the id and device
})
.sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId))
: [];
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
// Flatten into a single list with composite IDs "{cluster}-{device}" so the existing
// BatteryView table renders without further changes.
const sortedBatteryView = (() => {
if (props.values == null) return [];
if (
product === 4 &&
props.values.ListOfBatteriesRecord
) {
const flat: { BatteryId: string; battery: any }[] = [];
Object.entries(props.values.ListOfBatteriesRecord).forEach(
([clusterId, cluster]: [string, any]) => {
if (cluster?.Devices) {
Object.entries(cluster.Devices).forEach(([devId, dev]) => {
flat.push({ BatteryId: `${clusterId}-${devId}`, battery: dev });
});
}
}
);
return flat.sort((a, b) => a.BatteryId.localeCompare(b.BatteryId));
}
if (props.values?.Battery?.Devices) {
return Object.entries(props.values.Battery.Devices)
.map(([BatteryId, battery]) => ({ BatteryId, battery }))
.sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId));
}
return [];
})();
const [loading, setLoading] = useState(sortedBatteryView.length == 0);
@ -177,39 +199,37 @@ function BatteryView(props: BatteryViewProps) {
}
/>
{product === 0
? Object.entries(props.values.Battery.Devices).map(
([BatteryId, battery]) => (
<Route
key={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId}
element={
<DetailedBatteryView
batteryId={Number(BatteryId)}
s3Credentials={props.s3Credentials}
batteryData={battery}
installationId={props.installationId}
productNum={product}
></DetailedBatteryView>
}
/>
)
? sortedBatteryView.map(({ BatteryId, battery }) => (
<Route
key={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId}
element={
<DetailedBatteryView
batteryId={Number(BatteryId)}
s3Credentials={props.s3Credentials}
batteryData={battery}
installationId={props.installationId}
productNum={product}
></DetailedBatteryView>
}
/>
))
: sortedBatteryView.map(({ BatteryId, battery }) => (
<Route
key={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId}
element={
<DetailedBatteryViewSodistore
// Keep BatteryId as-is (Number("1-1") === NaN for product 4).
batteryId={BatteryId}
s3Credentials={props.s3Credentials}
batteryData={battery}
installationId={props.installationId}
productNum={product}
></DetailedBatteryViewSodistore>
}
/>
)
: Object.entries(props.values.Battery.Devices).map(
([BatteryId, battery]) => (
<Route
key={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId}
element={
<DetailedBatteryViewSodistore
batteryId={Number(BatteryId)}
s3Credentials={props.s3Credentials}
batteryData={battery}
installationId={props.installationId}
productNum={product}
></DetailedBatteryViewSodistore>
}
/>
)
)}
</Routes>
</Grid>
@ -262,7 +282,7 @@ function BatteryView(props: BatteryViewProps) {
component="th"
scope="row"
align="center"
sx={{ fontWeight: 'bold' }}
sx={{ fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '100px' }}
>
<Link
style={{ color: 'black' }}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import {
Card,
Container,
Grid,
Paper,
@ -24,6 +25,10 @@ import {
ActiveCluster,
getActiveClusters
} from '../Information/installationSetupUtils';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import { BatteryAnomalyLogEntry } from 'src/interfaces/BatteryAnomalyTypes';
interface BatteryViewSodioHomeProps {
values: JSONRecordData;
@ -47,6 +52,22 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
const hasDevices = !!inverter?.Devices;
const { currentUser } = useContext(UserContext);
const showAnomalyLog = currentUser?.userType === UserType.admin;
const [anomalyLog, setAnomalyLog] = useState<BatteryAnomalyLogEntry[]>([]);
useEffect(() => {
if (!showAnomalyLog || !props.installationId) return;
axiosConfig
.get('/GetBatteryVoltageAnomalyLog', {
params: { installationId: props.installationId }
})
.then((res) => {
if (Array.isArray(res.data)) setAnomalyLog(res.data);
})
.catch(() => setAnomalyLog([]));
}, [showAnomalyLog, props.installationId]);
const activeClusters: ActiveCluster[] = useMemo(() => {
const parsed = getActiveClusters(batterySerialNumbers || '');
if (parsed.length > 0) return parsed;
@ -376,6 +397,67 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
</TableContainer>
</Container>
)}
{showAnomalyLog && (
<Container maxWidth="lg" sx={{ marginTop: '20px' }}>
<Card>
<Typography
variant="h6"
sx={{ padding: '12px 16px', fontWeight: 'bold' }}
>
<FormattedMessage
id="voltageAnomalyLogTitle"
defaultMessage="Full-charge low-voltage log"
/>
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>
<FormattedMessage id="battery" defaultMessage="Battery" />
</TableCell>
<TableCell>
<FormattedMessage
id="voltageAnomalyTime"
defaultMessage="Time"
/>
</TableCell>
<TableCell>
<FormattedMessage id="voltage" defaultMessage="Voltage" />
</TableCell>
<TableCell>
<FormattedMessage id="soc" defaultMessage="SOC" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{anomalyLog.length === 0 ? (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noVoltageAnomalies"
defaultMessage="No anomalies recorded"
/>
</Typography>
</TableCell>
</TableRow>
) : (
anomalyLog.map((entry) => (
<TableRow key={entry.id}>
<TableCell>{entry.batteryLabel}</TableCell>
<TableCell>{entry.detectedAt}</TableCell>
<TableCell>{entry.voltage.toFixed(2)} V</TableCell>
<TableCell>{entry.soc.toFixed(0)} %</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Card>
</Container>
)}
</>
);
}

View File

@ -19,7 +19,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import GaugeChart from 'react-gauge-chart';
interface DetailedBatteryViewSodistoreProps {
batteryId: number;
// SodistoreGrid uses composite "{cluster}-{device}" IDs (e.g. "1-1"),
// so this is intentionally widened — the value is only rendered, never Number()'d.
batteryId: number | string;
s3Credentials: I_S3Credentials;
batteryData: Device;
installationId: number;

View File

@ -134,34 +134,22 @@ function MainStats(props: MainStatsProps) {
function generateSeries(chartData, category, color) {
const series = [];
const pathsToSearch = [
'Node0',
'Node1',
'Node2',
'Node3',
'Node4',
'Node5',
'Node6',
'Node7',
'Node8',
'Node9',
'Node10'
];
// Use all actually-present series keys so product 4 (SodiStoreGrid)
// composite names like "Node1-1".."Node3-6" are picked up too.
const presentPaths = Object.keys(chartData[category]?.data ?? {}).sort();
let i = 0;
pathsToSearch.forEach((devicePath) => {
if (
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
chartData[category].data[devicePath].data.length != 0
) {
presentPaths.forEach((devicePath) => {
if (chartData[category].data[devicePath]?.data?.length) {
const palette =
color === 'blue'
? blueColors
: color === 'red'
? redColors
: orangeColors;
series.push({
...chartData[category].data[devicePath],
color:
color === 'blue'
? blueColors[i]
: color === 'red'
? redColors[i]
: orangeColors[i]
color: palette[i % palette.length]
});
}
i++;

View File

@ -11,7 +11,7 @@ function phaseId(done: number, total: number): string {
if (total === 0) return 'checklistPhaseEmpty';
if (done >= total) return 'checklistPhaseComplete';
if (done <= 5) return 'checklistPhasePreparation';
if (done <= 12) return 'checklistPhaseOnSite';
if (done <= 11) return 'checklistPhaseOnSite';
return 'checklistPhaseHandover';
}

View File

@ -0,0 +1,279 @@
import { JSONRecordData } from '../Log/graph.util';
import {
Alert,
Box,
CardContent,
CircularProgress,
Container,
Grid,
IconButton,
TextField,
useTheme
} from '@mui/material';
import React, { useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import axiosConfig from '../../../Resources/axiosConfig';
interface ConfigurationSodistoreGridProps {
values: JSONRecordData;
id: number;
}
// Wire-format keys (camelCase → backend Configuration PascalCase via case-insensitive bind).
interface GridFormValues {
minimumSoC?: string;
activePowerPercent?: string;
minDischargeVoltage?: string;
maxDischargeCurrent?: string;
maxChargeCurrent?: string;
maxChargeVoltage?: string;
}
// Per-field validation rules.
// type="text" + regex (NOT type="number" — avoids spinner & locale issues, per project rule).
const FIELDS: {
name: keyof GridFormValues;
labelId: string;
min?: number;
max?: number;
allowNegative?: boolean;
}[] = [
{ name: 'minimumSoC', labelId: 'minimumSocPercent', min: 0, max: 100 },
{ name: 'activePowerPercent', labelId: 'activePowerPercent', min: -100, max: 100, allowNegative: true },
{ name: 'minDischargeVoltage', labelId: 'minDischargeVoltageV', min: 0 },
{ name: 'maxDischargeCurrent', labelId: 'maxDischargeCurrentA', min: 0 },
{ name: 'maxChargeCurrent', labelId: 'maxChargeCurrentA', min: 0 },
{ name: 'maxChargeVoltage', labelId: 'maxChargeVoltageV', min: 0 }
];
function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
const intl = useIntl();
const theme = useTheme();
// All hooks must be called unconditionally (Rules of Hooks). The null-check
// moved BELOW the hook declarations.
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
// Lazy initializer reads from props.values safely even when null.
const [formValues, setFormValues] = useState<GridFormValues>(() => {
const inv: any = props.values?.InverterRecord ?? {};
const cfg: any = props.values?.Config ?? {};
return {
minimumSoC: cfg.MinSoc != null ? String(cfg.MinSoc) : '',
activePowerPercent: cfg.ActivePowerPercent != null ? String(cfg.ActivePowerPercent) : '',
minDischargeVoltage: inv.MinDischargeVoltage != null ? String(inv.MinDischargeVoltage) : '',
maxDischargeCurrent: inv.MaxDischargeCurrent != null ? String(inv.MaxDischargeCurrent) : '',
maxChargeCurrent: inv.MaxChargeCurrent != null ? String(inv.MaxChargeCurrent) : '',
maxChargeVoltage: inv.MaxChargeVoltage != null ? String(inv.MaxChargeVoltage) : ''
};
});
const [errors, setErrors] = useState<Record<keyof GridFormValues, boolean>>({
minimumSoC: false,
activePowerPercent: false,
minDischargeVoltage: false,
maxDischargeCurrent: false,
maxChargeCurrent: false,
maxChargeVoltage: false
});
if (props.values === null) {
return null;
}
const validate = (
field: (typeof FIELDS)[number],
raw: string
): boolean => {
if (raw.trim() === '') return false; // empty = "leave unchanged", not error
// Regex: allow leading minus only if field accepts negatives, optional digits + dot
const pattern = field.allowNegative ? /^-?\d*\.?\d*$/ : /^\d*\.?\d*$/;
if (!pattern.test(raw)) return true;
const n = parseFloat(raw);
if (isNaN(n)) return true;
if (field.min !== undefined && n < field.min) return true;
if (field.max !== undefined && n > field.max) return true;
return false;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const field = FIELDS.find((f) => f.name === name);
if (!field) return;
setErrors((prev) => ({ ...prev, [name]: validate(field, value) }));
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const anyError = Object.values(errors).some(Boolean);
const allEmpty = Object.values(formValues).every(
(v) => !v || v.trim() === ''
);
const handleSubmit = async () => {
// Only send fields the user actually entered. Empty string = skip.
const payload: Record<string, number> = {};
FIELDS.forEach((f) => {
const raw = formValues[f.name];
if (raw && raw.trim() !== '') {
const n = parseFloat(raw);
if (!isNaN(n)) payload[f.name] = n;
}
});
if (Object.keys(payload).length === 0) return;
setLoading(true);
setError(false);
setUpdated(false);
try {
await axiosConfig.post(
`/EditInstallationConfig?installationId=${props.id}&product=4`,
payload
);
setUpdated(true);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
const helperFor = (name: keyof GridFormValues, hasError: boolean) => {
if (!hasError) return '';
const f = FIELDS.find((fld) => fld.name === name)!;
// Range-aware helper: 0100 → existing key; otherwise generic.
if (f.min === 0 && f.max === 100) {
return (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'valueBetween0And100' })}
</span>
);
}
return (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
</span>
);
};
return (
<Container maxWidth="xl">
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={3}
>
<Grid item xs={12} md={12}>
<CardContent>
<Box
component="form"
sx={{ '& .MuiTextField-root': { m: 1, width: 390 } }}
noValidate
autoComplete="off"
onSubmit={(e) => {
// Prevent native form submit (Enter in TextField would navigate).
e.preventDefault();
handleSubmit();
}}
>
{FIELDS.map((f) => (
<div key={f.name} style={{ marginBottom: '5px' }}>
<TextField
type="text"
label={<FormattedMessage id={f.labelId} />}
name={f.name}
value={formValues[f.name] ?? ''}
onChange={handleChange}
error={errors[f.name]}
helperText={helperFor(f.name, errors[f.name])}
fullWidth
/>
</div>
))}
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
variant="contained"
onClick={handleSubmit}
disabled={anyError || loading || allEmpty}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage
id="applyChanges"
defaultMessage="Apply changes"
/>
</Button>
{loading && (
<CircularProgress
sx={{
color: theme.palette.primary.main,
marginLeft: '20px'
}}
/>
)}
{updated && (
<Alert
severity="success"
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
>
<FormattedMessage
id="successfullyAppliedConfig"
defaultMessage="Successfully applied configuration file"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setUpdated(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
{error && (
<Alert
severity="error"
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
>
<FormattedMessage
id="configErrorOccurred"
defaultMessage="An error has occurred"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</CardContent>
</Grid>
</Grid>
</Container>
);
}
export default ConfigurationSodistoreGrid;

View File

@ -376,7 +376,14 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
})
.catch(() => {});
}
updateInstallation(formValues, props.type);
updateInstallation(
{
...formValues,
couplingType: formValues.couplingType || null,
backupLoad: formValues.backupLoad || null
},
props.type
);
};
const handleDelete = () => {
@ -912,18 +919,56 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</InputLabel>
<Select
name="couplingType"
value={formValues.couplingType || 'DC'}
value={formValues.couplingType || ''}
onChange={handleChange}
inputProps={{ readOnly: !canEdit }}
displayEmpty
notched
>
<MenuItem value="">
<em><FormattedMessage id="notSet" defaultMessage="Not set" /></em>
</MenuItem>
<MenuItem value="AC">
<FormattedMessage id="couplingAC" defaultMessage="AC-coupled" />
</MenuItem>
<MenuItem value="DC">
<FormattedMessage id="couplingDC" defaultMessage="DC-coupled" />
</MenuItem>
<MenuItem value="Mixed">
<FormattedMessage id="couplingMixed" defaultMessage="Mixed" />
</MenuItem>
</Select>
</FormControl>
</div>
<div>
<FormControl sx={{ m: 1, width: '50ch' }}>
<InputLabel
shrink
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
>
<FormattedMessage id="backupLoad" defaultMessage="Backup Load" />
</InputLabel>
<Select
name="backupLoad"
value={formValues.backupLoad || ''}
onChange={handleChange}
inputProps={{ readOnly: !canEdit }}
displayEmpty
notched
>
<MenuItem value="">
<em><FormattedMessage id="notSet" defaultMessage="Not set" /></em>
</MenuItem>
<MenuItem value="Whole">
<FormattedMessage id="backupWhole" defaultMessage="Whole house" />
</MenuItem>
<MenuItem value="Partial">
<FormattedMessage id="backupPartial" defaultMessage="Partial" />
</MenuItem>
<MenuItem value="None">
<FormattedMessage id="backupNone" defaultMessage="Not used" />
</MenuItem>
</Select>
</FormControl>
</div>

View File

@ -361,7 +361,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage
id="setupProgress"
defaultMessage="Setup Progress"
defaultMessage="Monitor Onboarding Progress"
/>
</TableCell>
)}
@ -550,7 +550,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
total={summary?.total ?? 13}
/>
);
})()}

View File

@ -25,9 +25,10 @@ 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 TopologySodistoreGrid from '../Topology/TopologySodistoreGrid';
import BatteryView from '../BatteryView/BatteryView';
import Configuration from '../Configuration/Configuration';
import ConfigurationSodistoreGrid from '../Configuration/ConfigurationSodistoreGrid';
import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
@ -51,6 +52,10 @@ function Installation(props: singleInstallationProps) {
const [currentTab, setCurrentTab] = useState<string>(undefined);
const [values, setValues] = useState<JSONRecordData | null>(null);
const status = props.current_installation.status;
// For SodiStoreGrid (product 4), backend heartbeat path is broken (SinexcelCommunication
// hardcodes Product=2), so installation.status stays -1 even when S3 is fresh.
// TODO: remove this override once SinexcelCommunication/Program.cs derives Product from runtime config.
// Treat as Green when our S3 fetch succeeded (connected === true).
const [connected, setConnected] = useState(true);
const [loading, setLoading] = useState(true);
@ -80,11 +85,61 @@ function Installation(props: singleInstallationProps) {
//While fetching, we check the value of continueFetching, if its false, we break. This means that either the user changed tab or the object has been finished its execution (return)
const continueFetching = useRef(false);
// SodiStoreGrid (Sinexcel) uploads chunked files every ~150s with arbitrary
// unix-second filenames (read key denies LIST — only GET is allowed).
// Probe a wide window in parallel batches with 1-second step.
// `checkContinue` lets the loop short-circuit when the user leaves Live,
// bounding wasted in-flight GETs to a single 20-request batch.
const probeLatestGridChunk = async (
maxAgeSeconds: number = 600,
checkContinue: () => boolean = () => true
): Promise<{ res: any; ts: any } | null> => {
const batchSize = 20;
const step = 1;
for (let batchStart = 0; batchStart < maxAgeSeconds; batchStart += batchSize * step) {
if (!checkContinue()) return null;
const offsets: number[] = [];
for (let j = 0; j < batchSize; j++) {
const offset = batchStart + j * step;
if (offset < maxAgeSeconds) offsets.push(offset);
}
const now = UnixTime.now();
const results = await Promise.all(
offsets.map(async (offset) => {
const ts = now.earlier(TimeSpan.fromSeconds(offset));
const r = await fetchDataJson(ts, s3Credentials, false);
return r !== FetchResult.notAvailable && r !== FetchResult.tryLater
? { res: r, ts }
: null;
})
);
const hit = results.find((r) => r !== null);
if (hit) return hit;
}
return null;
};
const fetchDataForOneTime = async () => {
var timeperiodToSearch = 70;
let res;
let timestampToFetch;
if (props.current_installation.product === 4) {
const hit = await probeLatestGridChunk(600);
if (!hit) {
setConnected(false);
setLoading(false);
return false;
}
setConnected(true);
setLoading(false);
const timestamps = Object.keys(hit.res).sort(
(a, b) => Number(a) - Number(b)
);
setValues(hit.res[timestamps[timestamps.length - 1]]);
return true;
}
for (var i = timeperiodToSearch; i > 0; i -= 2) {
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
try {
@ -122,6 +177,55 @@ function Installation(props: singleInstallationProps) {
let res;
let timestampToFetch;
// SodiStoreGrid: probe a wide window in parallel (read key denies LIST),
// stream through that chunk's timestamps, then refresh.
// Backoff schedule on consecutive misses to cap S3 cost on offline installs.
if (props.current_installation.product === 4) {
let firstHit = false;
let consecutiveMisses = 0;
const backoffMs = [30000, 60000, 120000, 300000]; // 30s → 60s → 2m → 5m (cap)
while (continueFetching.current) {
// Narrow window after first hit; widen back if we lose it.
const window = firstHit && consecutiveMisses === 0 ? 200 : 600;
const hit = await probeLatestGridChunk(
window,
() => continueFetching.current
);
if (!continueFetching.current) break;
if (!hit) {
consecutiveMisses += 1;
// Always reflect disconnection in the UI — even after a prior hit,
// so the user sees a stale-data signal instead of a frozen chart.
setConnected(false);
if (!firstHit) setLoading(false);
const wait = backoffMs[Math.min(consecutiveMisses - 1, backoffMs.length - 1)];
await timeout(wait);
continue;
}
consecutiveMisses = 0;
if (!firstHit) {
firstHit = true;
setLoading(false);
}
setConnected(true);
// Stream through chunk timestamps in ascending order (chunk = ~15 records, ~10s apart)
const orderedTs = Object.keys(hit.res).sort(
(a, b) => Number(a) - Number(b)
);
for (const t of orderedTs) {
if (!continueFetching.current) {
setFetchFunctionCalled(false);
return false;
}
setValues(hit.res[t]);
await timeout(2000);
}
await timeout(30000);
}
setFetchFunctionCalled(false);
return false;
}
for (var i = 0; i < timeperiodToSearch; i += 2) {
if (!continueFetching.current) {
return false;
@ -194,12 +298,14 @@ function Installation(props: singleInstallationProps) {
setCurrentTab(path[path.length - 1]);
}, [location]);
//If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component
//If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component.
//SodiStoreGrid (product 4) is excluded: backend heartbeat path is broken
//because SinexcelCommunication hardcodes Product=2 — trust S3 freshness instead.
useEffect(() => {
if (status === -1) {
if (status === -1 && props.current_installation.product !== 4) {
setConnected(false);
}
}, [status]);
}, [status, props.current_installation.product]);
useEffect(() => {
if (
@ -276,7 +382,7 @@ function Installation(props: singleInstallationProps) {
</Typography>
</div>
{currentTab == 'live' && values && (
{currentTab == 'live' && values && values.EssControl?.Mode && (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Typography
fontWeight="bold"
@ -326,7 +432,7 @@ function Installation(props: singleInstallationProps) {
marginTop: '-10px'
}}
>
{status === -1 ? (
{status === -1 && !(props.current_installation.product === 4 && connected) ? (
<CancelIcon
style={{
width: '23px',
@ -361,7 +467,8 @@ function Installation(props: singleInstallationProps) {
? 'red'
: status === 1
? 'orange'
: status === -1 || status === -2
: (status === -1 || status === -2) &&
!(props.current_installation.product === 4 && connected)
? 'transparent'
: 'green'
}}
@ -469,12 +576,11 @@ function Installation(props: singleInstallationProps) {
path={routes.live}
element={
props.current_installation.product === 4 ? (
<TopologySodistoreHome
<TopologySodistoreGrid
values={values}
connected={connected}
loading={loading}
batteryClusterNumber={props.current_installation.batteryClusterNumber}
></TopologySodistoreHome>
></TopologySodistoreGrid>
) : (
<Topology
values={values}
@ -501,20 +607,10 @@ function Installation(props: singleInstallationProps) {
path={routes.configuration}
element={
props.current_installation.product === 4 ? (
// TODO: SodistoreGrid — implement actual configuration
<Container
maxWidth="xl"
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '40vh'
}}
>
<Typography variant="body1" color="text.secondary">
Configuration not yet available
</Typography>
</Container>
<ConfigurationSodistoreGrid
values={values}
id={props.current_installation.id}
/>
) : (
<Configuration
values={values}

View File

@ -106,9 +106,9 @@ function InstallationTabs(props: InstallationTabsProps) {
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
const hidePvView = props.product === 4;
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view.
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows.
const showChecklist = props.product === 4;
// Checklist is not shown for any product in the Installations view.
// Salimax (0) / Salidomo (1) / SodistoreMax (3) / SodistoreGrid (4) use different onboarding flows.
const showChecklist = false;
const singleInstallationTabs = (
currentUser.userType == UserType.admin
@ -183,7 +183,7 @@ function InstallationTabs(props: InstallationTabsProps) {
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
label: <FormattedMessage id="checklist" defaultMessage="Monitor Onboarding Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -330,7 +330,7 @@ function InstallationTabs(props: InstallationTabsProps) {
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
label: <FormattedMessage id="checklist" defaultMessage="Monitor Onboarding Checklist" />
}
]
: currentUser.userType == UserType.partner

View File

@ -594,6 +594,19 @@ export interface JSONRecordData {
// [PvId: string]: PvString;
// };
// };
// For SodistoreGrid: list of battery clusters keyed by 1-based string IDs
ListOfBatteriesRecord?: {
[clusterId: string]: {
Soc: number;
Soh: number;
Voltage: number;
Current: number;
Power: number;
TemperatureCell1: number;
[key: string]: any;
};
};
}
export const parseChunkJson = (
@ -734,6 +747,12 @@ export type ConfigurationValues = {
timeToSellTo?: string;
timeToBuyFrom?: string;
timeToBuyTo?: string;
// For SodistoreHome + SodistorePro Sinexcel (device=4): relay control toggles
relay1?: boolean;
relay2?: boolean;
relay3?: boolean;
relay4?: boolean;
};
//
// export interface Pv {
@ -1174,7 +1193,9 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
'InverterRecord.TotalBatteryPower',
'InverterRecord.TotalPhotovoltaicPower',
'InverterRecord.TotalLoadPower',
'InverterRecord.TotalGridPower'
'InverterRecord.TotalGridPower',
'InverterRecord.ActivePowerW',
'DcDc.Dc.Battery.Power'
];
// Helper function to safely get a value from a nested path

View File

@ -10,13 +10,18 @@ import {
Alert,
Autocomplete,
Box,
CircularProgress,
Collapse,
Container,
Divider,
FormControl,
Grid,
IconButton,
InputLabel,
Link,
List,
ListItem,
ListItemButton,
ListSubheader,
MenuItem,
Modal,
@ -32,6 +37,9 @@ import Avatar from '@mui/material/Avatar';
import ListItemText from '@mui/material/ListItemText';
import PersonIcon from '@mui/icons-material/Person';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider';
@ -43,6 +51,8 @@ import {
I_Installation
} from '../../../interfaces/InstallationTypes';
import axiosConfig from '../../../Resources/axiosConfig';
import routes from 'src/Resources/routes.json';
import { useNavigate } from 'react-router-dom';
const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
const PRODUCT_NAMES: Record<number, string> = {
@ -72,6 +82,13 @@ function UserAccess(props: UserAccessProps) {
const [openFolder, setOpenFolder] = useState(false);
const [openModal, setOpenModal] = useState(false);
// Pending access revoke awaiting confirmation (folder or direct installation)
const [revokeTarget, setRevokeTarget] = useState<{
type: 'folder' | 'installation';
id: number;
name: string;
} | null>(null);
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
@ -91,7 +108,18 @@ function UserAccess(props: UserAccessProps) {
// Direct grants for this user
const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]);
const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]);
const [directInstallations, setDirectInstallations] = useState<
{ id: number; name: string; product: number }[]
>([]);
// Installations under each folder (lazy-loaded + cached), with expand/loading state
const navigate = useNavigate();
const [expandedFolderId, setExpandedFolderId] = useState<number | null>(null);
const [folderInstallations, setFolderInstallations] = useState<
Record<number, { id: number; name: string; product: number }[]>
>({});
const [loadingFolderId, setLoadingFolderId] = useState<number | null>(null);
const [folderError, setFolderError] = useState<Record<number, boolean>>({});
const accessContext = useContext(AccessContext);
const {
@ -169,6 +197,49 @@ function UserAccess(props: UserAccessProps) {
});
};
// Build the route to an installation from its product (mirrors CustomTreeItem).
// Targets the Tickets tab; the installation router redirects to `live` when the
// Tickets tab isn't available (non-admin), via its catch-all <Navigate to live>.
const installationLink = (inst: { id: number; product: number }): string => {
const base =
inst.product === 0
? routes.installations
: inst.product === 1
? routes.salidomo_installations
: inst.product === 2
? routes.sodiohome_installations
: inst.product === 4
? routes.sodistoregrid_installations
: inst.product === 5
? routes.sodistorepro_installations
: routes.sodistore_installations;
return base + routes.tree + routes.installation + inst.id + '/' + routes.installationTickets;
};
const fetchFolderInstallations = async (folderId: number) => {
setLoadingFolderId(folderId);
setFolderError((prev) => ({ ...prev, [folderId]: false }));
try {
const res = await axiosConfig.get(`/GetInstallationsUnderFolder?folderId=${folderId}`);
setFolderInstallations((prev) => ({ ...prev, [folderId]: res.data }));
} catch (err) {
if (err.response && err.response.status === 401) removeToken();
else setFolderError((prev) => ({ ...prev, [folderId]: true }));
} finally {
setLoadingFolderId(null);
}
};
const handleToggleFolder = (folderId: number) => {
if (expandedFolderId === folderId) {
setExpandedFolderId(null);
return;
}
setExpandedFolderId(folderId);
if (folderInstallations[folderId]) return; // already cached
fetchFolderInstallations(folderId);
};
const handleRevokeInstallation = async (installationId: number) => {
axiosConfig
.post(`/RevokeUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installationId}`)
@ -184,6 +255,16 @@ function UserAccess(props: UserAccessProps) {
});
};
const confirmRevoke = () => {
if (!revokeTarget) return;
if (revokeTarget.type === 'folder') {
handleRevokeFolder(revokeTarget.id, revokeTarget.name);
} else {
handleRevokeInstallation(revokeTarget.id);
}
setRevokeTarget(null);
};
const handleSubmit = async () => {
for (const folderName of selectedFolderNames) {
const folder = availableFolders.find((f) => f.name === folderName);
@ -341,6 +422,66 @@ function UserAccess(props: UserAccessProps) {
</Box>
</Modal>
{/* Revoke Access Confirmation Modal */}
<Modal
open={revokeTarget !== null}
onClose={() => setRevokeTarget(null)}
aria-labelledby="revoke-confirm-modal"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 350,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography variant="body1" gutterBottom sx={{ fontWeight: 'bold', textAlign: 'center' }}>
<FormattedMessage
id="confirmRevokeAccess"
defaultMessage='Do you want to remove access to "{name}"?'
values={{ name: revokeTarget?.name ?? '' }}
/>
</Typography>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 10 }}>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={confirmRevoke}
>
<FormattedMessage id="delete" defaultMessage="Delete" />
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={() => setRevokeTarget(null)}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
</Modal>
<Button
variant="contained"
onClick={handleGrantAccess}
@ -357,16 +498,32 @@ function UserAccess(props: UserAccessProps) {
</Typography>
{directFolders.map((folder, index) => {
const isLast = index === directFolders.length - 1;
const isExpanded = expandedFolderId === folder.id;
const installations = folderInstallations[folder.id];
return (
<Fragment key={folder.id}>
<ListItem
sx={{ mb: isLast ? 1 : 0 }}
sx={{ mb: isLast && !isExpanded ? 1 : 0 }}
secondaryAction={
currentUser.userType === UserType.admin && (
<IconButton onClick={() => handleRevokeFolder(folder.id, folder.name)} edge="end">
<PersonRemoveIcon />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton
onClick={() => handleToggleFolder(folder.id)}
edge="end"
aria-label={intl.formatMessage({ id: 'viewInstallations' })}
>
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
)
{currentUser.userType === UserType.admin && (
<IconButton
onClick={() =>
setRevokeTarget({ type: 'folder', id: folder.id, name: folder.name })
}
edge="end"
>
<PersonRemoveIcon />
</IconButton>
)}
</Box>
}
>
<ListItemAvatar>
@ -376,6 +533,60 @@ function UserAccess(props: UserAccessProps) {
</ListItemAvatar>
<ListItemText primary={folder.name} />
</ListItem>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
{loadingFolderId === folder.id ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={20} />
</Box>
) : folderError[folder.id] ? (
<Alert
severity="error"
sx={{ ml: 6, mr: 2, my: 1 }}
action={
<Button
color="inherit"
size="small"
onClick={() => fetchFolderInstallations(folder.id)}
>
<FormattedMessage id="retry" defaultMessage="Retry" />
</Button>
}
>
<FormattedMessage
id="couldNotLoadInstallations"
defaultMessage="Could not load installations"
/>
</Alert>
) : installations && installations.length > 0 ? (
<List disablePadding sx={{ pl: 6 }}>
{installations.map((inst) => (
<ListItemButton
key={inst.id}
onClick={() => navigate(installationLink(inst))}
sx={{ py: 0.25 }}
>
<ListItemAvatar sx={{ minWidth: 36 }}>
<InsertDriveFileIcon fontSize="small" color="action" />
</ListItemAvatar>
<ListItemText
primary={
<Link component="span" underline="hover">
{inst.name}
</Link>
}
/>
</ListItemButton>
))}
</List>
) : installations ? (
<Typography variant="body2" sx={{ pl: 6, py: 1, color: 'text.secondary' }}>
<FormattedMessage
id="noInstallationsInFolder"
defaultMessage="No installations in this folder"
/>
</Typography>
) : null}
</Collapse>
<Divider />
</Fragment>
);
@ -400,7 +611,16 @@ function UserAccess(props: UserAccessProps) {
sx={{ mb: isLast ? 4 : 0 }}
secondaryAction={
currentUser.userType === UserType.admin && (
<IconButton onClick={() => handleRevokeInstallation(installation.id)} edge="end">
<IconButton
onClick={() =>
setRevokeTarget({
type: 'installation',
id: installation.id,
name: installation.name
})
}
edge="end"
>
<PersonRemoveIcon />
</IconButton>
)
@ -411,7 +631,19 @@ function UserAccess(props: UserAccessProps) {
<PersonIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={installation.name} />
<ListItemText
primary={
<Link
component="button"
type="button"
underline="hover"
align="left"
onClick={() => navigate(installationLink(installation))}
>
{installation.name}
</Link>
}
/>
</ListItem>
<Divider />
</Fragment>

View File

@ -0,0 +1,203 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Checkbox,
FormControlLabel,
Stack,
TableCell,
TableRow,
TextField,
Typography
} from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import {
ON_SITE_STEP_META,
OnSiteChecklistItem,
OnSiteChecklistSubtask,
parseOnSiteSubtasks,
serializeOnSiteSubtasks
} from 'src/interfaces/OnSiteChecklistTypes';
import { ON_SITE_IMAGES } from 'src/assets/installation-checklist/sodistore-home';
interface Props {
item: OnSiteChecklistItem;
disabled: boolean;
onUpdate: (
id: number,
patch: Partial<{ checked: boolean; comments: string; subtasks: string }>
) => Promise<boolean>;
onImageClick: (filename: string, alt: string) => void;
}
function OnSiteChecklistStepRow({ item, disabled, onUpdate, onImageClick }: Props) {
const intl = useIntl();
const [comments, setComments] = useState(item.comments ?? '');
const [subtasks, setSubtasks] = useState<OnSiteChecklistSubtask[]>(() =>
parseOnSiteSubtasks(item.subtasks)
);
useEffect(() => {
setComments(item.comments ?? '');
}, [item.comments]);
useEffect(() => {
setSubtasks(parseOnSiteSubtasks(item.subtasks));
}, [item.subtasks]);
const meta = useMemo(
() => ON_SITE_STEP_META.find((m) => m.number === item.stepNumber),
[item.stepNumber]
);
const taskCount = subtasks.filter((s) => s.kind === 'task').length;
const checkedCount = subtasks.filter((s) => s.kind === 'task' && s.checked).length;
const allDone = taskCount > 0 && checkedCount === taskCount;
// Auto-mirror item.checked from all-substasks-done.
useEffect(() => {
if (disabled) return;
if (taskCount === 0) return;
if (allDone && !item.checked) {
onUpdate(item.id, { checked: true });
} else if (!allDone && item.checked) {
onUpdate(item.id, { checked: false });
}
}, [allDone, taskCount, item.checked, disabled, item.id, onUpdate]);
const handleSubtaskToggle = async (index: number) => {
if (disabled) return;
const updated = subtasks.map((s, i) => {
if (i !== index) return s;
if (s.kind !== 'task') return s;
return { ...s, checked: !s.checked };
});
setSubtasks(updated);
await onUpdate(item.id, { subtasks: serializeOnSiteSubtasks(updated) });
};
const handleCommentsBlur = async () => {
if (disabled) return;
if (comments !== (item.comments ?? '')) {
await onUpdate(item.id, { comments });
}
};
const stepTitle = meta
? intl.formatMessage({ id: meta.titleKey, defaultMessage: item.stepKey })
: item.stepKey;
return (
<TableRow hover sx={{ verticalAlign: 'top' }}>
<TableCell sx={{ width: 48, verticalAlign: 'top', pt: 2 }}>
<Typography variant="body1" fontWeight={700}>
{item.stepNumber}
</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 360, pt: 2 }}>
<Stack spacing={1}>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="h5" fontWeight={700}>
{stepTitle}
</Typography>
{taskCount > 0 && (
<Typography variant="body2" color={allDone ? 'success.main' : 'text.secondary'}>
({checkedCount}/{taskCount})
</Typography>
)}
</Stack>
<Box>
{subtasks.map((s, i) => {
if (s.kind === 'heading') {
return (
<Typography
key={`h-${i}`}
variant="subtitle2"
fontWeight={700}
sx={{ mt: 1.5, mb: 0.5 }}
>
<FormattedMessage id={s.heading} defaultMessage={s.heading} />
</Typography>
);
}
return (
<FormControlLabel
key={`t-${i}`}
control={
<Checkbox
size="small"
checked={s.checked}
disabled={disabled}
onChange={() => handleSubtaskToggle(i)}
/>
}
label={
<Typography variant="body2" component="span">
<FormattedMessage id={s.text} defaultMessage={s.text} />
</Typography>
}
sx={{ display: 'flex', alignItems: 'flex-start', ml: 0 }}
/>
);
})}
</Box>
{(meta?.warnings ?? []).map((w) => (
<Alert key={w.key} severity={w.severity} variant="outlined" sx={{ mt: 1 }}>
<FormattedMessage id={w.key} defaultMessage="" />
</Alert>
))}
<TextField
size="small"
multiline
minRows={1}
maxRows={6}
value={comments}
disabled={disabled}
onChange={(e) => setComments(e.target.value)}
onBlur={handleCommentsBlur}
fullWidth
placeholder={intl.formatMessage({
id: 'onSiteCommentsPlaceholder',
defaultMessage: 'Notizen, Beobachtungen…'
})}
sx={{ mt: 1, maxWidth: 600 }}
/>
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 260, pt: 2 }}>
<Stack spacing={1}>
{(meta?.images ?? []).map((filename) => {
const src = ON_SITE_IMAGES[filename];
if (!src) return null;
return (
<Box
key={filename}
component="img"
src={src}
alt={stepTitle}
onClick={() => onImageClick(filename, stepTitle)}
sx={{
width: '100%',
maxWidth: 240,
height: 'auto',
borderRadius: 1,
cursor: 'zoom-in',
border: '1px solid #e0e0e0',
transition: 'transform 0.1s',
'&:hover': { transform: 'scale(1.02)' }
}}
/>
);
})}
</Stack>
</TableCell>
</TableRow>
);
}
export default OnSiteChecklistStepRow;

View File

@ -0,0 +1,289 @@
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
LinearProgress,
Paper,
Snackbar,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
OnSiteChecklistItem,
OnSiteChecklistResponse,
OnSiteChecklistSignature
} from 'src/interfaces/OnSiteChecklistTypes';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import OnSiteChecklistStepRow from './OnSiteChecklistStepRow';
import OnSiteSignatureBlock from './OnSiteSignatureBlock';
import OnSiteImageLightbox from './OnSiteImageLightbox';
import { ON_SITE_IMAGES } from 'src/assets/installation-checklist/sodistore-home';
interface Props {
installationId: number;
}
type ToastState = {
open: boolean;
severity: 'success' | 'error';
message: string;
};
function OnSiteChecklistTab({ installationId }: Props) {
const intl = useIntl();
const { currentUser } = useContext(UserContext);
const [items, setItems] = useState<OnSiteChecklistItem[]>([]);
const [signature, setSignature] = useState<OnSiteChecklistSignature | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [toast, setToast] = useState<ToastState>({
open: false,
severity: 'success',
message: ''
});
const [lightbox, setLightbox] = useState<{ open: boolean; src: string | null; alt: string }>({
open: false,
src: null,
alt: ''
});
const fetchAll = useCallback(() => {
setLoading(true);
axiosConfig
.get('/GetOnSiteChecklistForInstallation', { params: { installationId } })
.then((res) => {
const data = res.data as OnSiteChecklistResponse;
setItems(Array.isArray(data?.items) ? data.items : []);
setSignature(data?.signature ?? null);
setError('');
})
.catch(() => setError('Failed to load on-site checklist.'))
.finally(() => setLoading(false));
}, [installationId]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const isSigned = !!signature?.signedAt;
const isAdmin = currentUser.userType === UserType.admin;
const disabled = isSigned && !isAdmin;
const progress = useMemo(() => {
const total = items.length;
const done = items.filter((i) => i.checked).length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
return { total, done, percent };
}, [items]);
const showSaveError = useCallback(() => {
setToast({
open: true,
severity: 'error',
message: intl.formatMessage({
id: 'onSiteSaveFailed',
defaultMessage: 'Änderung konnte nicht gespeichert werden'
})
});
}, [intl]);
const handleItemUpdate = useCallback(
async (
id: number,
patch: Partial<{ checked: boolean; comments: string; subtasks: string }>
) => {
const params: Record<string, unknown> = { onSiteChecklistItemId: id };
if (patch.checked !== undefined) params.checked = patch.checked;
if (patch.comments !== undefined) params.comments = patch.comments;
if (patch.subtasks !== undefined) params.subtasks = patch.subtasks;
try {
const res = await axiosConfig.put('/UpdateOnSiteChecklistItem', null, { params });
const updated = res.data as OnSiteChecklistItem;
setItems((prev) => prev.map((it) => (it.id === id ? updated : it)));
return true;
} catch {
showSaveError();
return false;
}
},
[showSaveError]
);
const handleRemarksChange = useCallback(
async (remarks: string) => {
try {
const res = await axiosConfig.put('/UpdateOnSiteChecklistRemarks', null, {
params: { installationId, remarks }
});
setSignature(res.data as OnSiteChecklistSignature);
return true;
} catch {
showSaveError();
return false;
}
},
[installationId, showSaveError]
);
const handleSign = useCallback(
async (signedByName: string) => {
try {
const res = await axiosConfig.post('/SignOnSiteChecklist', null, {
params: { installationId, signedByName }
});
setSignature(res.data as OnSiteChecklistSignature);
setToast({
open: true,
severity: 'success',
message: intl.formatMessage({
id: 'onSiteSignedSuccess',
defaultMessage: 'Checkliste erfolgreich unterzeichnet'
})
});
return true;
} catch {
showSaveError();
return false;
}
},
[installationId, showSaveError, intl]
);
const handleResetSignature = useCallback(async () => {
try {
const res = await axiosConfig.post('/ResetOnSiteChecklistSignature', null, {
params: { installationId }
});
setSignature(res.data as OnSiteChecklistSignature);
return true;
} catch {
showSaveError();
return false;
}
}, [installationId, showSaveError]);
const handleImageClick = (filename: string, alt: string) => {
setLightbox({ open: true, src: ON_SITE_IMAGES[filename] ?? null, alt });
};
if (loading) {
return (
<Box p={3}>
<LinearProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box p={2}>
<Box mb={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="h4">
<FormattedMessage
id="onSiteChecklistTitle"
defaultMessage="Vor-Ort-Installations-Checkliste"
/>
</Typography>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="onSiteChecklistProgress"
defaultMessage="Fortschritt: {done}/{total} ({percent}%)"
values={progress}
/>
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{ height: 8, borderRadius: 4 }}
/>
{disabled && (
<Alert severity="info" sx={{ mt: 2 }}>
<FormattedMessage
id="onSiteLocked"
defaultMessage="Diese Checkliste wurde unterzeichnet und ist gesperrt."
/>
</Alert>
)}
</Box>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ width: 48 }}>#</TableCell>
<TableCell>
<FormattedMessage id="onSiteStepHeading" defaultMessage="Schritt" />
</TableCell>
<TableCell sx={{ width: 260 }}>
<FormattedMessage id="onSiteImagesHeading" defaultMessage="Bilder" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<OnSiteChecklistStepRow
key={item.id}
item={item}
disabled={disabled}
onUpdate={handleItemUpdate}
onImageClick={handleImageClick}
/>
))}
</TableBody>
</Table>
</TableContainer>
{signature && (
<OnSiteSignatureBlock
signature={signature}
disabled={disabled}
currentUserType={currentUser.userType}
onRemarksChange={handleRemarksChange}
onSign={handleSign}
onResetSignature={handleResetSignature}
/>
)}
<OnSiteImageLightbox
open={lightbox.open}
src={lightbox.src}
alt={lightbox.alt}
onClose={() => setLightbox({ open: false, src: null, alt: '' })}
/>
<Snackbar
open={toast.open}
autoHideDuration={4000}
onClose={() => setToast((t) => ({ ...t, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={toast.severity}
onClose={() => setToast((t) => ({ ...t, open: false }))}
>
{toast.message}
</Alert>
</Snackbar>
</Box>
);
}
export default OnSiteChecklistTab;

View File

@ -0,0 +1,49 @@
import React from 'react';
import { Dialog, IconButton, Box } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
interface Props {
open: boolean;
src: string | null;
alt: string;
onClose: () => void;
}
function OnSiteImageLightbox({ open, src, alt, onClose }: Props) {
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<Box sx={{ position: 'relative', backgroundColor: '#000' }}>
<IconButton
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 8,
color: '#fff',
backgroundColor: 'rgba(0,0,0,0.4)',
'&:hover': { backgroundColor: 'rgba(0,0,0,0.7)' }
}}
>
<CloseIcon />
</IconButton>
{src && (
<Box
component="img"
src={src}
alt={alt}
onClick={onClose}
sx={{
display: 'block',
maxWidth: '100%',
maxHeight: '85vh',
margin: '0 auto',
cursor: 'zoom-out'
}}
/>
)}
</Box>
</Dialog>
);
}
export default OnSiteImageLightbox;

View File

@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
Box,
Button,
Collapse,
Link,
Paper,
Stack,
TextField,
Typography
} from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import { OnSiteChecklistSignature } from 'src/interfaces/OnSiteChecklistTypes';
import { UserType } from 'src/interfaces/UserTypes';
interface Props {
signature: OnSiteChecklistSignature;
disabled: boolean;
currentUserType: UserType;
onRemarksChange: (remarks: string) => Promise<boolean>;
onSign: (signedByName: string) => Promise<boolean>;
onResetSignature: () => Promise<boolean>;
}
function OnSiteSignatureBlock({
signature,
disabled,
currentUserType,
onRemarksChange,
onSign,
onResetSignature
}: Props) {
const intl = useIntl();
const [remarks, setRemarks] = useState(signature.remarks ?? '');
const [signedByName, setSignedByName] = useState('');
const [consentExpanded, setConsentExpanded] = useState(false);
useEffect(() => {
setRemarks(signature.remarks ?? '');
}, [signature.remarks]);
const isSigned = !!signature.signedAt;
const canResetSignature = isSigned && currentUserType === UserType.admin;
const handleRemarksBlur = async () => {
if (disabled) return;
if (remarks !== (signature.remarks ?? '')) {
await onRemarksChange(remarks);
}
};
const handleSign = async () => {
if (!signedByName.trim()) return;
await onSign(signedByName.trim());
};
const formatSignedDate = (iso: string | null): string => {
if (!iso) return '';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
};
return (
<Paper variant="outlined" sx={{ p: 3, mt: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h5" fontWeight={700} gutterBottom>
<FormattedMessage
id="onSiteRemarksTitle"
defaultMessage="Bemerkungen vom Installateur"
/>
</Typography>
<TextField
multiline
minRows={4}
maxRows={12}
fullWidth
value={remarks}
disabled={disabled}
onChange={(e) => setRemarks(e.target.value)}
onBlur={handleRemarksBlur}
placeholder={intl.formatMessage({
id: 'onSiteRemarksPlaceholder',
defaultMessage: 'Zusätzliche Bemerkungen…'
})}
/>
</Box>
<Box>
<Typography variant="h5" fontWeight={700} gutterBottom>
<FormattedMessage id="onSiteSignatureTitle" defaultMessage="E-Signatur" />
</Typography>
{isSigned ? (
<Alert
severity="success"
action={
canResetSignature ? (
<Button color="inherit" size="small" onClick={onResetSignature}>
<FormattedMessage
id="onSiteResetSignature"
defaultMessage="Unterschrift zurücksetzen"
/>
</Button>
) : undefined
}
>
<FormattedMessage
id="onSiteSignedBy"
defaultMessage="Unterzeichnet von {name} am {date}"
values={{
name: signature.signedByName ?? '',
date: formatSignedDate(signature.signedAt)
}}
/>
</Alert>
) : (
<Stack spacing={2}>
<Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-line' }}>
<FormattedMessage
id="onSiteConsentShort"
defaultMessage="Ich bestätige, dass die Installation gemäss obiger Checkliste fachgerecht ausgeführt und in Betrieb genommen wurde."
/>
</Typography>
<Link
component="button"
type="button"
variant="body2"
onClick={() => setConsentExpanded((v) => !v)}
sx={{ mt: 0.5 }}
>
{consentExpanded ? (
<FormattedMessage id="onSiteShowLess" defaultMessage="Weniger anzeigen" />
) : (
<FormattedMessage id="onSiteShowMore" defaultMessage="Mehr anzeigen" />
)}
</Link>
<Collapse in={consentExpanded}>
<Typography variant="body2" sx={{ mt: 1, whiteSpace: 'pre-line' }}>
<FormattedMessage
id="onSiteConsentLong"
defaultMessage="Mit der elektronischen Unterschrift bestätigt der ausführende Installateur, dass die Installation gemäss obiger Checkliste und den anerkannten Regeln der Technik ausgeführt und in Betrieb genommen wurde. Die obenstehenden Angaben sind vollständig und wahrheitsgetreu. Diese Bestätigung ist Teil der Inbetriebnahme und wird zusammen mit der Checkliste an inesco energy übermittelt."
/>
</Typography>
</Collapse>
</Box>
<TextField
label={intl.formatMessage({
id: 'onSiteFullName',
defaultMessage: 'Vollständiger Name'
})}
required
value={signedByName}
onChange={(e) => setSignedByName(e.target.value)}
disabled={disabled}
sx={{ maxWidth: 480 }}
/>
<Box>
<Button
variant="contained"
color="primary"
disabled={disabled || !signedByName.trim()}
onClick={handleSign}
>
<FormattedMessage id="onSiteSignButton" defaultMessage="Unterschreiben" />
</Button>
</Box>
</Stack>
)}
</Box>
</Stack>
</Paper>
);
}
export default OnSiteSignatureBlock;

View File

@ -7,7 +7,9 @@ export const getChartOptions = (
type: string,
dateList: string[],
stacked: Boolean,
voltageInfo?: chartInfoInterface
voltageInfo?: chartInfoInterface,
powerLabel?: string,
temperatureInfo?: chartInfoInterface
): ApexOptions => {
return type.includes('daily')
? {
@ -57,7 +59,7 @@ export const getChartOptions = (
type === 'dailyoverview'
? [
{
seriesName: 'Grid Power',
seriesName: powerLabel ?? 'Grid Power',
tickAmount: 6,
min:
chartInfo.min >= 0
@ -94,7 +96,7 @@ export const getChartOptions = (
}
},
{
seriesName: 'Grid Power',
seriesName: powerLabel ?? 'Grid Power',
show: false,
tickAmount: 6,
min:
@ -123,7 +125,7 @@ export const getChartOptions = (
}
},
{
seriesName: 'Grid Power',
seriesName: powerLabel ?? 'Grid Power',
show: false,
tickAmount: 6,
min:
@ -192,6 +194,27 @@ export const getChartOptions = (
return Math.round(value).toString();
}
}
}] : []),
...(temperatureInfo ? [{
seriesName: 'Battery Temperature',
opposite: true,
tickAmount: 5,
min: Math.floor((temperatureInfo.min - 5) / 5) * 5,
max: Math.ceil((temperatureInfo.max + 5) / 5) * 5,
title: {
text: '(°C)',
style: {
fontSize: '12px'
},
offsetY: -190,
offsetX: -65,
rotate: 0
},
labels: {
formatter: function (value: number) {
return Math.round(value).toString();
}
}
}] : [])
]
: {
@ -241,18 +264,27 @@ export const getChartOptions = (
},
y: {
formatter: function (val, { seriesIndex, w }) {
// `shared: true` calls this for every series at the hovered x,
// even when a particular series has no data point there → val undefined.
if (val === undefined || val === null || Number.isNaN(val)) {
return '-';
}
const seriesName = w.config.series[seriesIndex].name;
if (seriesName === 'Battery SOC') {
return val.toFixed(2) + ' %';
} else if (seriesName === 'Battery Voltage') {
return val.toFixed(2) + ' (V)';
} else if (seriesName === 'Battery Temperature') {
return val.toFixed(2) + ' (°C)';
} else {
const formatted = formatPowerForGraph(val, chartInfo.magnitude);
const raw = formatted?.value;
return (
formatPowerForGraph(val, chartInfo.magnitude).value.toFixed(
2
) +
(raw === undefined || raw === null || Number.isNaN(raw)
? '-'
: raw.toFixed(2)) +
' ' +
chartInfo.unit
(chartInfo.unit ?? '')
);
}
}

View File

@ -566,7 +566,12 @@ function Overview(props: OverviewProps) {
>
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button>
{props.device !== 3 && props.product !== 2 && (
{/* Hide "Last week" for SodioHome (product 2) and SodiStoreGrid (product 4)
neither has aggregated weekly data. Uses context `product` because
`props.product` isn't passed from Installation.tsx; `props.device` is also
never passed, so the legacy `device !== 3` (Growatt) check was a no-op
Growatt installs all have product=2, so the product check covers them. */}
{product !== 2 && product !== 4 && (
<Button
variant="contained"
onClick={handleWeekData}
@ -663,7 +668,7 @@ function Overview(props: OverviewProps) {
</Container>
)}
{!loading && dailyDataArray.length > 0 && (
{!loading && ((dailyData && dailyDataArray.length > 0) || (aggregatedData && aggregatedDataArray.length > 0)) && (
<Grid item xs={12} md={12}>
{dailyData && (
<Grid
@ -709,11 +714,22 @@ function Overview(props: OverviewProps) {
<ReactApexChart
options={{
...getChartOptions(
dailyDataArray[chartState].chartOverview.overview,
// For SodiStoreGrid (product 4), the "overview" yaxis
// bucket is unused — drive the left power axis from
// Battery Power instead so SOC matches its % axis correctly.
product === 4
? dailyDataArray[chartState].chartOverview.dcPower
: dailyDataArray[chartState].chartOverview.overview,
'dailyoverview',
[],
true,
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
(product === 2 || product === 5 || product === 4)
? dailyDataArray[chartState].chartOverview.batteryVoltage
: undefined,
product === 4 ? 'Battery Power' : undefined,
product === 4
? dailyDataArray[chartState].chartOverview.temperature
: undefined
),
chart: {
events: {
@ -725,32 +741,64 @@ function Overview(props: OverviewProps) {
}
}}
series={[
{
...dailyDataArray[chartState].chartData.gridPower,
type: 'line',
color: '#b30000'
},
{
...dailyDataArray[chartState].chartData
.pvProduction,
type: 'line',
color: '#ff9900'
},
{
...dailyDataArray[chartState].chartData.ACLoad,
type: 'line',
color: '#2ecc71'
},
// SodiStoreGrid (product 4) has no grid meter, no PV, no AC load.
...(product !== 4
? [
{
...dailyDataArray[chartState].chartData
.gridPower,
type: 'line' as const,
color: '#b30000'
},
{
...dailyDataArray[chartState].chartData
.pvProduction,
type: 'line' as const,
color: '#ff9900'
},
{
...dailyDataArray[chartState].chartData
.ACLoad,
type: 'line' as const,
color: '#2ecc71'
}
]
: [
// For SodiStoreGrid, replace the empty grid/PV/load
// series with Battery Power (drives the left kW axis).
{
...dailyDataArray[chartState].chartData
.dcPower,
name: 'Battery Power',
type: 'line' as const,
color: '#e67e22'
}
]),
{
...dailyDataArray[chartState].chartData.soc,
type: 'line',
color: '#008FFB'
},
...((product === 2 || product === 5) ? [{
...dailyDataArray[chartState].chartData.batteryVoltage,
type: 'line' as const,
color: '#9b59b6'
}] : [])
...(product === 2 || product === 5 || product === 4
? [
{
...dailyDataArray[chartState].chartData
.batteryVoltage,
type: 'line' as const,
color: '#9b59b6'
}
]
: []),
...(product === 4
? [
{
...dailyDataArray[chartState].chartData
.temperature,
type: 'line' as const,
color: '#16a085'
}
]
: [])
]}
height={420}
/>
@ -1215,6 +1263,8 @@ function Overview(props: OverviewProps) {
</Grid>
)}
{/* PV Production + Grid Power — hidden for SodiStoreGrid (no PV, no grid meter) */}
{product !== 4 && (
<Grid
container
direction="row"
@ -1403,6 +1453,7 @@ function Overview(props: OverviewProps) {
</Card>
</Grid>
</Grid>
)}
{aggregatedData && (product === 2 || product === 5) && (
<Grid
@ -1461,7 +1512,7 @@ function Overview(props: OverviewProps) {
</Grid>
)}
{dailyData && (
{dailyData && product !== 4 && (
<Grid
container
direction="row"

View File

@ -0,0 +1,742 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import {
Box,
Card,
Chip,
CircularProgress,
Collapse,
Container,
FormControl,
Grid,
IconButton,
InputLabel,
ListItemText,
MenuItem,
OutlinedInput,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography
} from '@mui/material';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import ErrorIcon from '@mui/icons-material/Error';
import WarningIcon from '@mui/icons-material/Warning';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import DownloadIcon from '@mui/icons-material/Download';
import RefreshIcon from '@mui/icons-material/Refresh';
import { FormattedMessage, useIntl } from 'react-intl';
import { AxiosError, AxiosResponse } from 'axios';
import axiosConfig from '../../../Resources/axiosConfig';
import routes from '../../../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
import { TokenContext } from '../../../contexts/tokenContext';
interface AlarmReportRow {
id: number;
installationId: number;
installationName: string;
product: number;
device: number;
severity: 'error' | 'warning';
description: string;
date: string; // yyyy-MM-dd
time: string; // HH:mm:ss
deviceCreatedTheMessage: string;
seen: boolean;
}
interface AlarmGroup {
key: string;
installationId: number;
installationName: string;
product: number;
device: number;
severity: 'error' | 'warning';
description: string;
count: number;
firstSeen: string; // "yyyy-MM-dd HH:mm:ss"
lastSeen: string;
events: AlarmReportRow[];
}
// Product / device names are brand names — not translated (see i18n convention).
const PRODUCT_LABELS: Record<number, string> = {
0: 'Salimax',
1: 'Salidomo',
2: 'Sodistore Home',
3: 'Sodistore Max',
4: 'Sodistore Grid',
5: 'Sodistore Pro'
};
const DEVICE_LABELS: Record<number, string> = {
1: 'Cerbo',
2: 'Venus',
3: 'Growatt',
4: 'Sinexcel'
};
const productLabel = (p: number) => PRODUCT_LABELS[p] ?? `Product ${p}`;
const deviceLabel = (d: number) => DEVICE_LABELS[d] ?? `Device ${d}`;
type RangePreset = 'all' | 'today' | 'last7' | 'thisMonth' | 'lastMonth' | 'custom';
type SeverityFilter = 'both' | 'error' | 'warning';
type ViewMode = 'grouped' | 'raw';
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
const toIsoDate = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
/**
* Resolve a preset into an inclusive [from, to] yyyy-MM-dd window, or null for "all".
* NOTE: uses the viewer's local timezone. Alarm dates are stamped in Swiss time; an
* admin viewing from a far-off timezone near midnight could see a day-boundary skew.
* Acceptable for v1 (almost all admins are in CH); revisit with Europe/Zurich math if needed.
*/
const resolveRange = (
preset: RangePreset,
customFrom: string,
customTo: string
): { from: string | null; to: string | null } => {
const now = new Date();
const today = toIsoDate(now);
switch (preset) {
case 'today':
return { from: today, to: today };
case 'last7': {
const d = new Date(now);
d.setDate(d.getDate() - 6);
return { from: toIsoDate(d), to: today };
}
case 'thisMonth': {
const first = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: toIsoDate(first), to: today };
}
case 'lastMonth': {
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const last = new Date(now.getFullYear(), now.getMonth(), 0);
return { from: toIsoDate(first), to: toIsoDate(last) };
}
case 'custom':
return { from: customFrom || null, to: customTo || null };
case 'all':
default:
return { from: null, to: null };
}
};
function Report() {
const intl = useIntl();
const navigate = useNavigate();
const { removeToken } = useContext(TokenContext);
const [rows, setRows] = useState<AlarmReportRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [severity, setSeverity] = useState<SeverityFilter>('both');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDevices, setSelectedDevices] = useState<number[]>([]);
const [installationFilter, setInstallationFilter] = useState<number | 'all'>('all');
const [rangePreset, setRangePreset] = useState<RangePreset>('all');
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
/** "AbnormalGridVoltage" → "Abnormal Grid Voltage", then i18n lookup. */
const splitCamelCase = (s: string) =>
s.replace(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' ').trim();
const alarmDisplayName = (description: string) =>
intl.formatMessage({
id: `alarm_${description}`,
defaultMessage: splitCamelCase(description)
});
const loadReport = () => {
setLoading(true);
setLoadError(false);
axiosConfig
.get('/GetAlarmReport?severity=both')
.then((res: AxiosResponse<AlarmReportRow[]>) => {
setRows(res.data);
setLoading(false);
})
.catch((err: AxiosError) => {
if (err.response && err.response.status === 401) {
removeToken();
navigate(routes.login);
return;
}
setLoadError(true);
setLoading(false);
});
};
useEffect(() => {
loadReport();
}, []);
// Filter option lists derived from the data itself.
const availableProducts = useMemo(
() => Array.from(new Set(rows.map((r) => r.product))).sort((a, b) => a - b),
[rows]
);
const availableDevices = useMemo(
() => Array.from(new Set(rows.map((r) => r.device))).sort((a, b) => a - b),
[rows]
);
const availableInstallations = useMemo(() => {
const map = new Map<number, string>();
rows.forEach((r) => map.set(r.installationId, r.installationName));
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [rows]);
const range = resolveRange(rangePreset, customFrom, customTo);
const filteredRows = useMemo(() => {
const term = search.trim().toLowerCase();
return rows.filter((r) => {
if (severity !== 'both' && r.severity !== severity) return false;
if (selectedProducts.length && !selectedProducts.includes(r.product)) return false;
if (selectedDevices.length && !selectedDevices.includes(r.device)) return false;
if (installationFilter !== 'all' && r.installationId !== installationFilter) return false;
if (range.from && r.date < range.from) return false;
if (range.to && r.date > range.to) return false;
if (term) {
const hay = `${r.installationName} ${alarmDisplayName(r.description)} ${r.deviceCreatedTheMessage}`.toLowerCase();
if (!hay.includes(term)) return false;
}
return true;
});
}, [rows, severity, selectedProducts, selectedDevices, installationFilter, range.from, range.to, search]);
const groups = useMemo(() => {
const map = new Map<string, AlarmGroup>();
filteredRows.forEach((r) => {
const key = `${r.installationId}|${r.device}|${r.description}|${r.severity}`;
const stamp = `${r.date} ${r.time}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
key,
installationId: r.installationId,
installationName: r.installationName,
product: r.product,
device: r.device,
severity: r.severity,
description: r.description,
count: 1,
firstSeen: stamp,
lastSeen: stamp,
events: [r]
});
} else {
existing.count += 1;
existing.events.push(r);
if (stamp < existing.firstSeen) existing.firstSeen = stamp;
if (stamp > existing.lastSeen) existing.lastSeen = stamp;
}
});
return Array.from(map.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
}, [filteredRows]);
const rawSorted = useMemo(
() =>
[...filteredRows].sort((a, b) =>
`${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
),
[filteredRows]
);
const toggleExpand = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// Deep-link to an installation's Log tab (mirrors the Ticket detail navigation).
const openInstallationLog = (product: number, installationId: number) => {
const productRoutes: Record<number, string> = {
0: routes.installations,
1: routes.salidomo_installations,
2: routes.sodiohome_installations,
3: routes.sodistore_installations,
4: routes.sodistoregrid_installations,
5: routes.sodistorepro_installations
};
const prefix = productRoutes[product] ?? routes.installations;
navigate(prefix + routes.list + routes.installation + installationId + '/' + routes.log);
};
const installationCell = (name: string, product: number, installationId: number) => (
<TableCell>
<Typography
variant="body2"
onClick={() => openInstallationLog(product, installationId)}
sx={{
color: 'primary.main',
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' }
}}
>
{name}
</Typography>
<Typography variant="caption" color="text.secondary">
{productLabel(product)}
</Typography>
</TableCell>
);
const severityIcon = (sev: 'error' | 'warning') =>
sev === 'error' ? (
<ErrorIcon color="error" />
) : (
<WarningIcon color="warning" />
);
const exportCsv = () => {
const header = [
'Installation',
'Product',
'Device',
'Severity',
'Alarm',
'Count',
'First seen (CH)',
'Last seen (CH)'
];
const lines =
viewMode === 'grouped'
? groups.map((g) => [
g.installationName,
productLabel(g.product),
deviceLabel(g.device),
g.severity,
alarmDisplayName(g.description),
String(g.count),
g.firstSeen,
g.lastSeen
])
: rawSorted.map((r) => [
r.installationName,
productLabel(r.product),
deviceLabel(r.device),
r.severity,
alarmDisplayName(r.description),
'1',
`${r.date} ${r.time}`,
`${r.date} ${r.time}`
]);
// Neutralize CSV formula injection (installation names are user-editable):
// a leading =,+,-,@ would be executed as a formula by Excel/LibreOffice.
const sanitize = (v: string) => (/^[=+\-@]/.test(v) ? `'${v}` : v);
const escape = (v: string) => `"${sanitize(v).replace(/"/g, '""')}"`;
const csv = [header, ...lines].map((row) => row.map(escape).join(',')).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alarm_report_${toIsoDate(new Date())}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const renderMultiSelect = (
labelId: string,
label: React.ReactNode,
values: number[],
options: number[],
optionLabel: (n: number) => string,
onChange: (next: number[]) => void
) => (
<FormControl size="small" fullWidth>
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
multiple
value={values}
input={<OutlinedInput label={label} />}
renderValue={(selected) =>
(selected as number[]).map(optionLabel).join(', ')
}
onChange={(e) => onChange(e.target.value as number[])}
>
{options.map((opt) => (
<MenuItem key={opt} value={opt}>
<Checkbox checked={values.includes(opt)} />
<ListItemText primary={optionLabel(opt)} />
</MenuItem>
))}
</Select>
</FormControl>
);
return (
<Container maxWidth="xl" sx={{ mt: 3, mb: 4 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1} flexWrap="wrap">
<Typography variant="h3">
<FormattedMessage id="alarmReportTitle" defaultMessage="Alarm Report" />
</Typography>
<Box display="flex" gap={1}>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadReport}
disabled={loading}
>
<FormattedMessage id="refresh" defaultMessage="Refresh" />
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={exportCsv}
disabled={filteredRows.length === 0}
>
<FormattedMessage id="exportCsv" defaultMessage="Export CSV" />
</Button>
</Box>
</Box>
<Typography variant="body2" color="text.secondary" mb={2}>
<FormattedMessage
id="alarmReportRetentionNote"
defaultMessage="Times shown in Swiss time (CET/CEST). Showing currently retained alarms — up to the last 100 events per installation."
/>
</Typography>
<Card sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="severity-label">
<FormattedMessage id="filterSeverity" defaultMessage="Severity" />
</InputLabel>
<Select
labelId="severity-label"
value={severity}
label={intl.formatMessage({ id: 'filterSeverity', defaultMessage: 'Severity' })}
onChange={(e) => setSeverity(e.target.value as SeverityFilter)}
>
<MenuItem value="both">
<FormattedMessage id="severityBoth" defaultMessage="Errors & Warnings" />
</MenuItem>
<MenuItem value="error">
<FormattedMessage id="severityErrors" defaultMessage="Errors only" />
</MenuItem>
<MenuItem value="warning">
<FormattedMessage id="severityWarnings" defaultMessage="Warnings only" />
</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6} md={3}>
{renderMultiSelect(
'product-label',
<FormattedMessage id="filterProduct" defaultMessage="Product" />,
selectedProducts,
availableProducts,
productLabel,
setSelectedProducts
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
{renderMultiSelect(
'device-label',
<FormattedMessage id="filterDevice" defaultMessage="Device" />,
selectedDevices,
availableDevices,
deviceLabel,
setSelectedDevices
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="installation-label">
<FormattedMessage id="filterInstallation" defaultMessage="Installation" />
</InputLabel>
<Select
labelId="installation-label"
value={installationFilter}
label={intl.formatMessage({ id: 'filterInstallation', defaultMessage: 'Installation' })}
onChange={(e) =>
setInstallationFilter(
e.target.value === 'all' ? 'all' : Number(e.target.value)
)
}
>
<MenuItem value="all">
<FormattedMessage id="filterAllInstallations" defaultMessage="All installations" />
</MenuItem>
{availableInstallations.map((i) => (
<MenuItem key={i.id} value={i.id}>
{i.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="range-label">
<FormattedMessage id="filterTimeRange" defaultMessage="Time range" />
</InputLabel>
<Select
labelId="range-label"
value={rangePreset}
label={intl.formatMessage({ id: 'filterTimeRange', defaultMessage: 'Time range' })}
onChange={(e) => setRangePreset(e.target.value as RangePreset)}
>
<MenuItem value="all">
<FormattedMessage id="rangeAll" defaultMessage="All retained" />
</MenuItem>
<MenuItem value="today">
<FormattedMessage id="rangeToday" defaultMessage="Today" />
</MenuItem>
<MenuItem value="last7">
<FormattedMessage id="rangeLast7" defaultMessage="Past 7 days" />
</MenuItem>
<MenuItem value="thisMonth">
<FormattedMessage id="rangeThisMonth" defaultMessage="This month" />
</MenuItem>
<MenuItem value="lastMonth">
<FormattedMessage id="rangeLastMonth" defaultMessage="Last month" />
</MenuItem>
<MenuItem value="custom">
<FormattedMessage id="rangeCustom" defaultMessage="Custom…" />
</MenuItem>
</Select>
</FormControl>
</Grid>
{rangePreset === 'custom' && (
<>
<Grid item xs={6} sm={3} md={2}>
<TextField
size="small"
fullWidth
type="date"
label={intl.formatMessage({ id: 'rangeFrom', defaultMessage: 'From' })}
InputLabelProps={{ shrink: true }}
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
</Grid>
<Grid item xs={6} sm={3} md={2}>
<TextField
size="small"
fullWidth
type="date"
label={intl.formatMessage({ id: 'rangeTo', defaultMessage: 'To' })}
InputLabelProps={{ shrink: true }}
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</Grid>
</>
)}
<Grid item xs={12} sm={6} md={3}>
<TextField
size="small"
fullWidth
label={intl.formatMessage({ id: 'filterSearch', defaultMessage: 'Search' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="view-label">
<FormattedMessage id="filterView" defaultMessage="View" />
</InputLabel>
<Select
labelId="view-label"
value={viewMode}
label={intl.formatMessage({ id: 'filterView', defaultMessage: 'View' })}
onChange={(e) => setViewMode(e.target.value as ViewMode)}
>
<MenuItem value="grouped">
<FormattedMessage id="viewGrouped" defaultMessage="Grouped" />
</MenuItem>
<MenuItem value="raw">
<FormattedMessage id="viewRaw" defaultMessage="Raw events" />
</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Card>
{loading ? (
<Box display="flex" justifyContent="center" p={4}>
<CircularProgress />
</Box>
) : loadError ? (
<Typography color="error">
<FormattedMessage
id="alarmReportLoadError"
defaultMessage="Failed to load the alarm report."
/>
</Typography>
) : (
<Card>
<Box px={2} py={1}>
<Typography variant="body2" color="text.secondary">
{viewMode === 'grouped' ? (
<FormattedMessage
id="alarmReportGroupCount"
defaultMessage="{groups} distinct alarms · {events} events"
values={{ groups: groups.length, events: filteredRows.length }}
/>
) : (
<FormattedMessage
id="alarmReportEventCount"
defaultMessage="{events} events"
values={{ events: filteredRows.length }}
/>
)}
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
{viewMode === 'grouped' && <TableCell />}
<TableCell>
<FormattedMessage id="colInstallation" defaultMessage="Installation" />
</TableCell>
<TableCell>
<FormattedMessage id="colDevice" defaultMessage="Device" />
</TableCell>
<TableCell>
<FormattedMessage id="colAlarm" defaultMessage="Alarm" />
</TableCell>
<TableCell align="center">
<FormattedMessage id="colSeverity" defaultMessage="Type" />
</TableCell>
{viewMode === 'grouped' ? (
<>
<TableCell align="center">
<FormattedMessage id="colCount" defaultMessage="Count" />
</TableCell>
<TableCell>
<FormattedMessage id="colFirstSeen" defaultMessage="First seen (CH)" />
</TableCell>
<TableCell>
<FormattedMessage id="colLastSeen" defaultMessage="Last seen (CH)" />
</TableCell>
</>
) : (
<>
<TableCell>
<FormattedMessage id="colDate" defaultMessage="Date" />
</TableCell>
<TableCell>
<FormattedMessage id="colTime" defaultMessage="Time (CH)" />
</TableCell>
</>
)}
</TableRow>
</TableHead>
<TableBody>
{viewMode === 'grouped'
? groups.map((g) => (
<React.Fragment key={g.key}>
<TableRow hover>
<TableCell padding="checkbox">
<IconButton size="small" onClick={() => toggleExpand(g.key)}>
{expanded.has(g.key) ? (
<KeyboardArrowUpIcon />
) : (
<KeyboardArrowDownIcon />
)}
</IconButton>
</TableCell>
{installationCell(g.installationName, g.product, g.installationId)}
<TableCell>{deviceLabel(g.device)}</TableCell>
<TableCell>{alarmDisplayName(g.description)}</TableCell>
<TableCell align="center">{severityIcon(g.severity)}</TableCell>
<TableCell align="center">
<Chip size="small" label={`${g.count}×`} />
</TableCell>
<TableCell>{g.firstSeen}</TableCell>
<TableCell>{g.lastSeen}</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ py: 0, borderBottom: 'none' }} colSpan={8}>
<Collapse in={expanded.has(g.key)} timeout="auto" unmountOnExit>
<Box my={1} ml={6}>
<Table size="small">
<TableBody>
{[...g.events]
.sort((a, b) =>
`${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
)
.map((ev) => (
<TableRow key={ev.id}>
<TableCell>{ev.deviceCreatedTheMessage}</TableCell>
<TableCell>{ev.date}</TableCell>
<TableCell>{ev.time}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
))
: rawSorted.map((r) => (
<TableRow hover key={r.id}>
{installationCell(r.installationName, r.product, r.installationId)}
<TableCell>{deviceLabel(r.device)}</TableCell>
<TableCell>{alarmDisplayName(r.description)}</TableCell>
<TableCell align="center">{severityIcon(r.severity)}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>{r.time}</TableCell>
</TableRow>
))}
{filteredRows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 3 }}>
<Typography color="text.secondary">
<FormattedMessage
id="alarmReportNoResults"
defaultMessage="No alarms match the selected filters."
/>
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
</Container>
);
}
export default Report;

View File

@ -0,0 +1,184 @@
import React, { useEffect, useState } from 'react';
import { Box, Button, Typography } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { FormattedMessage, useIntl } from 'react-intl';
import ReactApexChart from 'react-apexcharts';
import ApexCharts, { ApexOptions } from 'apexcharts';
import { UnixTime, TimeSpan } from 'src/dataCache/time';
import {
CurrentPriceSeries,
fetchCurrentPriceHistory
} from './currentPriceData';
interface CurrentPriceChartProps {
id: number;
}
const HISTORY_DAYS = 7;
const CHART_ID = 'current-price-history';
function CurrentPriceChart(props: CurrentPriceChartProps) {
const intl = useIntl();
const [series, setSeries] = useState<CurrentPriceSeries | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
const end = UnixTime.now();
const start = end.earlier(TimeSpan.fromDays(HISTORY_DAYS));
fetchCurrentPriceHistory(props.id, start, end)
.then((result) => {
if (!cancelled) {
setSeries(result);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setSeries({ data: [], min: 0, max: 0 });
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [props.id]);
// Snap the x-axis back to the full history window after the user drag-zooms.
const handleResetZoom = () => {
if (!series || series.data.length === 0) return;
const minX = series.data[0][0];
const maxX = series.data[series.data.length - 1][0];
ApexCharts.exec(CHART_ID, 'zoomX', minX, maxX);
};
const title = (
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
<FormattedMessage
id="currentPriceHistory"
defaultMessage="Current Price (last 7 days)"
/>
</Typography>
);
if (loading) {
return (
<Box sx={{ mb: 2 }}>
{title}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 280
}}
>
<CircularProgress size={36} sx={{ color: '#ffc04d' }} />
</Box>
</Box>
);
}
if (!series || series.data.length === 0) {
return (
<Box sx={{ mb: 2 }}>
{title}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<FormattedMessage
id="currentPriceNoData"
defaultMessage="No price history available for the last 7 days."
/>
</Typography>
</Box>
);
}
const options: ApexOptions = {
chart: {
id: CHART_ID,
type: 'line',
height: 300,
// Toolbar hidden — its zoom glyphs are easy to miss; we expose an explicit
// "Reset zoom" button instead. Drag-to-zoom still works via zoom.enabled.
toolbar: { show: false },
zoom: { enabled: true, type: 'x', autoScaleYaxis: true }
},
dataLabels: { enabled: false },
// Spot price is constant within each pricing interval -> stepped line
stroke: { curve: 'stepline', width: 2 },
colors: ['#5569ff'],
xaxis: {
type: 'datetime',
labels: {
datetimeFormatter: {
year: 'yyyy',
month: "MMM 'yy",
day: 'dd MMM',
hour: 'HH:mm'
}
}
},
yaxis: {
min: 0,
tickAmount: 5,
title: { text: 'CHF/kWh', style: { fontSize: '12px' } },
labels: { formatter: (v: number) => (v == null ? '' : v.toFixed(3)) }
},
tooltip: {
x: { format: 'dd MMM HH:mm' },
y: {
formatter: (v: number) =>
v == null || Number.isNaN(v) ? '-' : v.toFixed(3) + ' CHF/kWh'
}
},
grid: { padding: { top: 10 } }
};
const chartSeries = [
{
name: intl.formatMessage({ id: 'currentPrice' }),
data: series.data
}
];
return (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<FormattedMessage
id="currentPriceHistory"
defaultMessage="Current Price (last 7 days)"
/>
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={handleResetZoom}
>
<FormattedMessage id="resetZoom" defaultMessage="Reset zoom" />
</Button>
</Box>
<ReactApexChart
options={options}
series={chartSeries}
type="line"
height={300}
/>
</Box>
);
}
export default CurrentPriceChart;

View File

@ -19,6 +19,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { getDeviceTypeName } from '../Information/installationSetupUtils';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
@ -27,6 +28,10 @@ import {
CHECKLIST_ENABLED_PRODUCTS,
ChecklistSummary
} from 'src/interfaces/ChecklistTypes';
import {
BatteryAnomalySummary,
VOLTAGE_ANOMALY_PRODUCTS
} from 'src/interfaces/BatteryAnomalyTypes';
import SetupProgress from '../Checklist/SetupProgress';
interface FlatInstallationViewProps {
@ -44,12 +49,18 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const showChecklistColumn =
currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1);
const showAnomalyColumn =
currentUser?.userType === UserType.admin &&
VOLTAGE_ANOMALY_PRODUCTS.has(props.product ?? -1);
const isListViewPath =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map()
);
const [anomalyMap, setAnomalyMap] = useState<Map<number, BatteryAnomalySummary>>(
new Map()
);
useEffect(() => {
if (!showChecklistColumn || !isListViewPath) return;
@ -66,6 +77,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
.catch(() => setProgressMap(new Map()));
}, [showChecklistColumn, isListViewPath]);
useEffect(() => {
if (!showAnomalyColumn || !isListViewPath) return;
axiosConfig
.get('/GetBatteryVoltageAnomalySummary')
.then((res) => {
if (!Array.isArray(res.data)) return;
const map = new Map<number, BatteryAnomalySummary>();
res.data.forEach((s: BatteryAnomalySummary) => {
map.set(s.installationId, s);
});
setAnomalyMap(map);
})
.catch(() => setAnomalyMap(new Map()));
}, [showAnomalyColumn, isListViewPath]);
const sortedInstallations = useMemo(() => {
return [...props.installations].sort((a, b) => {
// Data-collection-disabled installations sink below everything (even offline).
@ -154,7 +180,15 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage
id="setupProgress"
defaultMessage="Setup Progress"
defaultMessage="Monitor Onboarding Progress"
/>
</TableCell>
)}
{showAnomalyColumn && (
<TableCell>
<FormattedMessage
id="batteryVoltageAnomaly"
defaultMessage="Full @ low voltage"
/>
</TableCell>
)}
@ -329,12 +363,25 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
total={summary?.total ?? 13}
/>
);
})()}
</TableCell>
)}
{showAnomalyColumn && (
<TableCell>
{anomalyMap.has(installation.id) && (
<WarningAmberIcon
style={{
width: '20px',
height: '20px',
color: '#f7b34d'
}}
/>
)}
</TableCell>
)}
</HoverableTableRow>
);
})}

View File

@ -32,6 +32,7 @@ import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import InstallationChecklistTab from '../Checklist/InstallationChecklistTab';
import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes';
import OnSiteChecklistTab from '../OnSiteChecklist/OnSiteChecklistTab';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -499,7 +500,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab != 'report' &&
currentTab != 'installationTickets' &&
currentTab != 'documents' &&
currentTab != 'checklist' && (
currentTab != 'checklist' &&
currentTab != 'onsiteChecklist' && (
<Container
maxWidth="xl"
sx={{
@ -685,6 +687,18 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/>
)}
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) &&
props.current_installation.product === 2 && (
<Route
path={routes.onSiteChecklist}
element={
<OnSiteChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>}

View File

@ -43,6 +43,7 @@ import { DateTimePicker, TimePicker } from '@mui/x-date-pickers';
import dayjs from 'dayjs';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import CurrentPriceChart from './CurrentPriceChart';
interface SodistoreHomeConfigurationProps {
values: JSONRecordData;
@ -161,6 +162,10 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
relay1: String((props.values.Config as any).Relay1).toLowerCase() === "true",
relay2: String((props.values.Config as any).Relay2).toLowerCase() === "true",
relay3: String((props.values.Config as any).Relay3).toLowerCase() === "true",
relay4: String((props.values.Config as any).Relay4).toLowerCase() === "true",
dynamicPricingMode: (props.values.Config as any).DynamicPricingMode ?? 'Disabled',
currentPrice: (props.values.Config as any).CurrentPrice?.toString() ?? '',
priceToSell: (props.values.Config as any).PriceToSell?.toString() ?? '',
@ -347,6 +352,14 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
: null,
controlPermission:formValues.controlPermission,
// Relay control — only send for Sinexcel (device=4); leaving them undefined
// ensures they're omitted from the UDP payload for Growatt installations.
...(device === 4 ? {
relay1: Boolean(formValues.relay1),
relay2: Boolean(formValues.relay2),
relay3: Boolean(formValues.relay3),
relay4: Boolean(formValues.relay4),
} : {}),
dynamicPricingMode: formValues.dynamicPricingMode,
currentPrice: formValues.currentPrice,
priceToSell: formValues.priceToSell,
@ -522,6 +535,84 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
/>
</div>
{device === 4 && (product === 2 || product === 5) && (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
<FormattedMessage id="digitalInputs" defaultMessage="Digital Inputs" />
</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, mb: 1, ml: 1 }}>
{[1, 2, 3, 4].map((n) => {
const raw = (props.values.Config as any)[`DigitalInput${n}`];
// No datapath (field absent/null) → grey; otherwise green/red
const indicator =
raw == null
? '⚪'
: String(raw).toLowerCase() === 'true'
? '🟢'
: '🔴';
return (
<Box
key={`digitalInput${n}`}
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<span style={{ fontSize: '1.1rem', lineHeight: 1 }}>
{indicator}
</span>
<Typography component="span">
<FormattedMessage
id={`digitalInput${n}`}
defaultMessage={`Digital Input ${n}`}
/>
</Typography>
</Box>
);
})}
</Box>
</>
)}
{device === 4 && (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
<FormattedMessage id="relayControl" defaultMessage="Relay Control" />
</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, mb: 1, ml: 1 }}>
{[1, 2, 3, 4].map((n) => {
const key = `relay${n}` as 'relay1' | 'relay2' | 'relay3' | 'relay4';
return (
<FormControlLabel
key={key}
labelPlacement="start"
control={
<Switch
name={key}
checked={Boolean(formValues[key])}
onChange={(e) => {
setFormDirty(true);
setFormValues((prev) => ({
...prev,
[key]: e.target.checked,
}));
}}
sx={{ transform: 'scale(1.2)', ml: 1 }}
/>
}
sx={{ ml: 0, mr: 0 }}
label={
<FormattedMessage
id={`relay${n}`}
defaultMessage={`Relay ${n}`}
/>
}
/>
);
})}
</Box>
</>
)}
{(device === 3 || device === 4) && (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
@ -592,6 +683,8 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
{clusters.map((_slotCount, clIdx) => {
const clKey = `Cluster${clIdx + 1}`;
const cluster = formValues.devices?.[invKey]?.Clusters?.[clKey];
// Hide clusters with no installed batteries — Information tab is source of truth.
if (cluster?.BatteryCount === 0) return null;
const charge = cluster?.MaxChargingCurrent ?? '';
const discharge = cluster?.MaxDischargingCurrent ?? '';
const setClusterField = (
@ -906,6 +999,10 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
/>
</div>
<div style={{ marginBottom: '15px', marginTop: '10px' }}>
<CurrentPriceChart id={props.id} />
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'priceToSell' })}

View File

@ -0,0 +1,62 @@
import { AxiosResponse } from 'axios';
import axiosConfig from 'src/Resources/axiosConfig';
import { UnixTime } from 'src/dataCache/time';
// History of the `/Config/CurrentPrice` value (CHF/kWh) for the Configuration tab chart.
// The backend GetCurrentPriceHistory endpoint reads the per-15-minute price from S3
// chunks server-side and returns ready-to-plot points, so the browser makes one call.
export interface CurrentPriceSeries {
// [timestampMs, price] — timestampMs is local-time-shifted like the other charts
data: [number, number][];
min: number;
max: number;
}
interface PricePointDto {
timestamp: number; // unix seconds (UTC)
price: number; // CHF/kWh
}
const EMPTY: CurrentPriceSeries = { data: [], min: 0, max: 0 };
// Match the other dashboards: render unix-second timestamps shifted into the
// browser's local zone so the x-axis reads as local time.
const toLocalMs = (ticks: number): number => {
const d = new Date(ticks * 1000);
d.setHours(d.getHours() - d.getTimezoneOffset() / 60);
return d.getTime();
};
export const fetchCurrentPriceHistory = async (
id: number,
start: UnixTime,
end: UnixTime
): Promise<CurrentPriceSeries> => {
let points: PricePointDto[] = [];
try {
const res: AxiosResponse<PricePointDto[]> = await axiosConfig.get(
`/GetCurrentPriceHistory?id=${id}&start=${start.ticks}&end=${end.ticks}`
);
points = res.data ?? [];
} catch {
return EMPTY;
}
const data: [number, number][] = [];
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
points.forEach((p) => {
if (!p || p.price === undefined || p.price === null || Number.isNaN(p.price)) {
return;
}
data.push([toLocalMs(p.timestamp), p.price]);
if (p.price < min) min = p.price;
if (p.price > max) max = p.price;
});
if (data.length === 0) return EMPTY;
data.sort((a, b) => a[0] - b[0]);
return { data, min, max };
};

View File

@ -54,7 +54,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'report',
'installationTickets',
'documents',
'checklist'
'checklist',
'onSiteChecklist'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -196,7 +197,11 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
label: <FormattedMessage id="checklist" defaultMessage="Monitor Onboarding Checklist" />
},
{
value: 'onSiteChecklist',
label: <FormattedMessage id="onSiteChecklist" defaultMessage="On-Site Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -240,6 +245,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'onSiteChecklist',
label: <FormattedMessage id="onSiteChecklist" defaultMessage="On-Site Checklist" />
}
]
: [
@ -284,7 +293,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
const dataCollectionDisabled =
currentInstallation?.dataCollectionEnabled === false
|| (installations.length === 1 && installations[0].dataCollectionEnabled === false);
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents', 'checklist'];
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents', 'checklist', 'onSiteChecklist'];
const tabs = inInstallationView && currentUser.userType == UserType.admin
? [
@ -369,7 +378,11 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
label: <FormattedMessage id="checklist" defaultMessage="Monitor Onboarding Checklist" />
},
{
value: 'onSiteChecklist',
label: <FormattedMessage id="onSiteChecklist" defaultMessage="On-Site Checklist" />
}
]
: inInstallationView && currentUser.userType == UserType.partner
@ -421,6 +434,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'onSiteChecklist',
label: <FormattedMessage id="onSiteChecklist" defaultMessage="On-Site Checklist" />
}
]
: inInstallationView && currentUser.userType == UserType.client
@ -486,6 +503,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
>
{tabs
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.filter((tab) => !(tab.value === 'onSiteChecklist' && props.product !== 2))
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
.map((tab) => (
<Tab
@ -560,6 +578,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
>
{singleInstallationTabs
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.filter((tab) => !(tab.value === 'onSiteChecklist' && props.product !== 2))
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
.map((tab) => (
<Tab

View File

@ -78,9 +78,26 @@ function TicketList() {
.catch(() => {});
}, []);
// Group partner names that differ only by case / whitespace so the same
// partner does not appear as multiple dropdown entries (e.g. "Breu AG" vs
// "Breu Ag"). Normalize for matching, but show one canonical label per group.
const normalizePartner = (p: string) =>
p.trim().replace(/\s+/g, ' ').toLowerCase();
const partnerOptions = Array.from(
new Set(tickets.map((t) => t.distributionPartner).filter((p) => p && p.trim() !== ''))
).sort();
tickets
.map((t) => t.distributionPartner)
.filter((p) => p && p.trim() !== '')
.reduce((map, p) => {
const key = normalizePartner(p);
const display = p.trim().replace(/\s+/g, ' ');
const current = map.get(key);
// One canonical label per group; pick the alphabetically first spelling.
if (!current || display.localeCompare(current) < 0) map.set(key, display);
return map;
}, new Map<string, string>())
.values()
).sort((a, b) => a.localeCompare(b));
const assigneeOptions = adminUsers
.filter((u) => {
@ -99,7 +116,9 @@ function TicketList() {
t.subject.toLowerCase().includes(search.toLowerCase()) ||
t.installationName.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status);
const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter;
const matchesPartner =
partnerFilter === '' ||
normalizePartner(t.distributionPartner ?? '') === normalizePartner(partnerFilter);
const matchesAssignee =
assigneeFilter === '' ||
(assigneeFilter === '__unassigned__'

View File

@ -0,0 +1,232 @@
import React, { useState } from 'react';
import {
CircularProgress,
Container,
Grid,
Switch,
Typography
} from '@mui/material';
import TopologyColumn from './topologyColumn';
import {
getAmount,
getHighestConnectionValue,
JSONRecordData
} from '../Log/graph.util';
import { FormattedMessage } from 'react-intl';
interface TopologySodistoreGridProps {
values: JSONRecordData;
connected: boolean;
loading: boolean;
}
function TopologySodistoreGrid(props: TopologySodistoreGridProps) {
if (props.values === null && props.connected == true) {
return null;
}
const highestConnectionValue =
props.values != null ? getHighestConnectionValue(props.values) : 0;
const [showValues, setShowValues] = useState(false);
const handleSwitch = () => () => {
setShowValues(!showValues);
};
const isMobile = window.innerWidth <= 1490;
const inv = props.values?.InverterRecord;
const dcdc = props.values?.DcDc;
const clusters = props.values?.ListOfBatteriesRecord ?? {};
const clusterIds = Object.keys(clusters).sort(
(a, b) => Number(a) - Number(b)
);
const acDcPower = Number(inv?.ActivePowerW ?? 0);
const dcLinkPower = Number(dcdc?.Dc?.Link?.Power ?? 0);
const dcLinkVoltage = Number(dcdc?.Dc?.Link?.Voltage ?? 0);
const dcdcBatteryVoltage = Number(dcdc?.Dc?.Battery?.Voltage ?? 0);
const dcdcBatteryPower = Number(dcdc?.Dc?.Battery?.Power ?? 0);
return (
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
<Grid container>
{!props.connected && !props.loading && (
<Container
maxWidth="xl"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '70vh'
}}
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography
variant="body2"
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
<FormattedMessage
id="unableToCommunicate"
defaultMessage="Unable to communicate with the installation"
/>
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
<FormattedMessage
id="pleaseWaitOrRefresh"
defaultMessage="Please wait or refresh the page"
/>
</Typography>
</Container>
)}
{props.connected && (
<>
<Grid
item
xs={12}
md={12}
style={{
marginTop: '10px',
height: '20px',
display: 'flex',
flexDirection: 'row',
alignItems: 'right',
justifyContent: 'right'
}}
>
<div>
<Typography sx={{ marginTop: '5px', marginRight: '20px' }}>
Display Values
</Typography>
<Switch
edge="start"
color="secondary"
onChange={handleSwitch()}
sx={{
'& .MuiSwitch-thumb': {
backgroundColor: 'orange'
},
marginLeft: '20px'
}}
/>
</div>
</Grid>
<Grid
item
xs={12}
md={12}
style={{
height: isMobile ? '550px' : '600px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}
>
{/* AC-DC */}
<TopologyColumn
centerBox={{
title: 'AC-DC',
data: inv
? [{ value: acDcPower, unit: 'W' }]
: undefined,
connected: true
}}
centerConnection={{
orientation: 'horizontal',
data: inv ? { value: acDcPower, unit: 'W' } : undefined,
amount: inv
? getAmount(highestConnectionValue, acDcPower)
: 0,
showValues: showValues
}}
isLast={false}
isFirst={true}
/>
{/* DC Link */}
<TopologyColumn
centerBox={{
title: 'DC Link',
data: dcdc
? [{ value: dcLinkVoltage, unit: 'V' }]
: undefined,
connected: true
}}
centerConnection={{
orientation: 'horizontal',
data: dcdc
? { value: dcLinkPower, unit: 'W' }
: undefined,
amount: dcdc
? getAmount(highestConnectionValue, dcLinkPower)
: 0,
showValues: showValues
}}
isLast={false}
isFirst={false}
/>
{/* DC-DC */}
<TopologyColumn
centerBox={{
title: 'DC-DC',
data: dcdc
? [{ value: dcdcBatteryVoltage, unit: 'V' }]
: undefined,
connected: true
}}
centerConnection={{
orientation: 'horizontal',
data: dcdc
? { value: dcdcBatteryPower, unit: 'W' }
: undefined,
amount: dcdc
? getAmount(highestConnectionValue, dcdcBatteryPower)
: 0,
showValues: showValues
}}
isLast={false}
isFirst={false}
/>
{/* Battery clusters — one column per cluster, no chained lines */}
{clusterIds.map((id) => {
const c = clusters[id];
return (
<TopologyColumn
key={`battery-cluster-${id}`}
centerBox={{
title: `Battery ${id}`,
data: c
? [
{ value: Number(c.Soc ?? 0), unit: '%' },
{ value: Number(c.Voltage ?? 0), unit: 'V' },
{ value: Number(c.Current ?? 0), unit: 'A' },
{
value: Number(c.TemperatureCell1 ?? 0),
unit: '°C'
}
]
: undefined,
connected: true
}}
isLast={true}
isFirst={false}
/>
);
})}
</Grid>
</>
)}
</Grid>
</Container>
);
}
export default TopologySodistoreGrid;

View File

@ -67,7 +67,7 @@ function TopologyBox(props: TopologyBoxProps) {
width: isMobile ? '90px' : '104px',
height:
props.title === 'Battery'
props.title === 'Battery' || (props.title && props.title.startsWith('Battery '))
? '165px'
: props.title === 'AC Loads' ||
props.title === 'DC Loads' ||

View File

@ -13,23 +13,43 @@ import {
useTheme
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import { useSearchParams } from 'react-router-dom';
import { InnovEnergyUser, UserType } from 'src/interfaces/UserTypes';
import User from './User';
// Translation keys for each UserType, indexed by the enum value
// (client=0, partner=1, admin=2).
export const USER_ROLE_LABEL_IDS: Record<UserType, string> = {
[UserType.client]: 'roleClient',
[UserType.partner]: 'rolePartner',
[UserType.admin]: 'roleAdmin'
};
interface FlatUsersViewProps {
users: InnovEnergyUser[];
fetchDataAgain: () => void;
}
const FlatUsersView = (props: FlatUsersViewProps) => {
const [selectedUser, setSelectedUser] = useState<number>(-1);
// Selected user is kept in the URL (?userId=) so navigating away to an
// installation and pressing Back restores this user's detail pane.
const [searchParams, setSearchParams] = useSearchParams();
const userIdParam = searchParams.get('userId');
const [selectedUser, setSelectedUser] = useState<number>(
userIdParam ? Number(userIdParam) : -1
);
const handleSelectOneUser = (userID: number): void => {
if (selectedUser != userID) {
setSelectedUser(userID);
const next = selectedUser !== userID ? userID : -1;
setSelectedUser(next);
const params = new URLSearchParams(searchParams);
if (next === -1) {
params.delete('userId');
params.delete('tab');
} else {
setSelectedUser(-1);
params.set('userId', String(next));
}
setSearchParams(params, { replace: true });
};
const theme = useTheme();
@ -48,17 +68,26 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
const isMobile = window.innerWidth <= 1490;
// Only show the detail pane once a real user is picked; until then let the
// list span the full width so long emails aren't clipped by a reserved-but-
// empty pane.
const selectedUserObj = findUser(selectedUser);
const hasSelection = selectedUser !== -1 && selectedUserObj !== undefined;
return (
<Grid container spacing={1} sx={{ marginTop: '1px' }}>
<Grid item xs={6} md={5}>
<Grid item xs={12} md={hasSelection ? 5 : 12}>
<Card>
<Divider />
<TableContainer sx={{ maxHeight: '520px', overflowY: 'auto' }}>
<TableContainer
sx={{ maxHeight: 'calc(100vh - 260px)', overflowY: 'auto' }}
>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox"></TableCell>
<TableCell><FormattedMessage id="username" defaultMessage="Username" /></TableCell>
<TableCell><FormattedMessage id="role" defaultMessage="Role" /></TableCell>
<TableCell><FormattedMessage id="email" defaultMessage="Email" /></TableCell>
</TableRow>
</TableHead>
@ -95,6 +124,21 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
{user.name}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body1"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
>
<FormattedMessage
id={
USER_ROLE_LABEL_IDS[user.userType] ?? 'roleClient'
}
/>
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body1"
@ -114,14 +158,14 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
</TableContainer>
</Card>
</Grid>
<Grid item xs={6} md={7}>
{selectedUser && (
{hasSelection && (
<Grid item xs={12} md={7}>
<User
current_user={findUser(selectedUser)}
current_user={selectedUserObj}
fetchDataAgain={props.fetchDataAgain}
></User>
)}
</Grid>
</Grid>
)}
</Grid>
);
};

View File

@ -16,6 +16,7 @@ import {
Tab,
Tabs,
TextField,
Tooltip,
Typography,
useTheme
} from '@mui/material';
@ -24,8 +25,10 @@ import Button from '@mui/material/Button';
import axiosConfig from 'src/Resources/axiosConfig';
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import { TokenContext } from 'src/contexts/tokenContext';
import { UserContext } from 'src/contexts/userContext';
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
import { FormattedMessage, useIntl } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
import UserAccess from '../ManageAccess/UserAccess';
interface singleUserProps {
@ -39,10 +42,22 @@ function User(props: singleUserProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
const [currentTab, setCurrentTab] = useState<string>('user');
// Tracks whether the last successful save changed the email (re-registration banner).
const [emailChanged, setEmailChanged] = useState(false);
// Specific error message key for the email-change failure cases; null => generic error.
const [errorMsgId, setErrorMsgId] = useState<string | null>(null);
// Confirmation dialog shown before an email change (resets password, emails both addresses).
const [openModalEmailChange, setOpenModalEmailChange] = useState(false);
// Active tab is kept in the URL (?tab=) alongside ?userId= so Back restores it.
const [searchParams, setSearchParams] = useSearchParams();
const [currentTab, setCurrentTab] = useState<string>(
searchParams.get('tab') === 'manage' ? 'manage' : 'user'
);
const [formValues, setFormValues] = useState(props.current_user);
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
const userContext = useContext(UserContext);
const loggedInUser = userContext?.currentUser;
const tabs = [
{ value: 'user', label: intl.formatMessage({ id: 'user' }) },
{ value: 'manage', label: intl.formatMessage({ id: 'accessManagement' }) }
@ -82,6 +97,9 @@ function User(props: singleUserProps) {
const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => {
setCurrentTab(value);
setError(false);
const params = new URLSearchParams(searchParams);
params.set('tab', value);
setSearchParams(params, { replace: true });
};
const handleChange = (e) => {
@ -99,7 +117,13 @@ function User(props: singleUserProps) {
});
};
const handleSubmit = async (e) => {
// Basic email-format guard (frontend half of the validation; backend enforces too).
const emailIsValid = (email: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((email ?? '').trim());
// Performs the actual update request. Called directly for non-email edits, or from
// the confirmation dialog when the email changed.
const doUpdate = async (didEmailChange: boolean) => {
setLoading(true);
setError(false);
@ -107,6 +131,18 @@ function User(props: singleUserProps) {
.put(`/UpdateUser`, formValues)
.catch((err) => {
if (err.response) {
if (didEmailChange && err.response.status === 500) {
// Backend reverted the change because the set-password email failed.
setErrorMsgId('userEmailChangeFailedReverted');
} else if (didEmailChange && err.response.status === 401) {
// Email already in use by another user, or not permitted (admins only).
setErrorMsgId('userEmailChangeNotAllowedOrTaken');
} else if (didEmailChange && err.response.status === 400) {
// Backend rejected a malformed email (frontend normally catches this first).
setErrorMsgId('invalidEmailFormat');
} else {
setErrorMsgId(null);
}
setError(true);
setLoading(false);
}
@ -115,6 +151,7 @@ function User(props: singleUserProps) {
if (res) {
props.fetchDataAgain();
setLoading(false);
setEmailChanged(didEmailChange);
setUpdated(true);
setTimeout(() => {
@ -123,6 +160,37 @@ function User(props: singleUserProps) {
}
};
const handleSubmit = async (e) => {
setError(false);
// Compare against the originally-loaded email to detect a re-registration.
const didEmailChange = formValues.email !== props.current_user.email;
// Reject a malformed new email before anything is sent (defense alongside backend guard).
if (didEmailChange && !emailIsValid(formValues.email)) {
setErrorMsgId('invalidEmailFormat');
setError(true);
return;
}
// An email change resets the password and emails both addresses — confirm first.
if (didEmailChange) {
setOpenModalEmailChange(true);
return;
}
await doUpdate(false);
};
const emailChangeModalConfirm = async () => {
setOpenModalEmailChange(false);
await doUpdate(true);
};
const emailChangeModalCancel = () => {
setOpenModalEmailChange(false);
};
const handleDelete = (e) => {
setLoading(true);
setError(false);
@ -161,6 +229,17 @@ function User(props: singleUserProps) {
const isMobile = window.innerWidth <= 1490;
// Mirror the backend delete guards: the main/root admin (the parentless top
// user — parentId 0; its id is NOT necessarily 0) and your own account can
// never be deleted, so disable the button and explain why.
const isMainAdmin = formValues.parentId <= 0 || formValues.id === 0;
const deleteDisabledReason = isMainAdmin
? intl.formatMessage({ id: 'cannotDeleteMainAdmin' })
: loggedInUser?.id === formValues.id
? intl.formatMessage({ id: 'cannotDeleteSelf' })
: '';
const cannotDelete = deleteDisabledReason !== '';
return (
<>
{openModalDeleteUser && (
@ -231,6 +310,94 @@ function User(props: singleUserProps) {
</Modal>
)}
{openModalEmailChange && (
<Modal
open={openModalEmailChange}
onClose={emailChangeModalCancel}
aria-labelledby="email-change-modal"
aria-describedby="email-change-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography
variant="body1"
gutterBottom
sx={{ fontWeight: 'bold' }}
>
<FormattedMessage
id="emailChangeConfirmTitle"
defaultMessage="Change this user's email?"
/>
</Typography>
<Typography
variant="body2"
sx={{ mt: 1, textAlign: 'center', wordBreak: 'break-all' }}
>
{props.current_user.email} {formValues.email}
</Typography>
<Typography
variant="body2"
sx={{ mt: 2, textAlign: 'center' }}
>
<FormattedMessage
id="emailChangeConfirmWarning"
defaultMessage="This resets the user's password and emails both the new and the previous address. The user must set a new password via the link sent to the new address."
/>
</Typography>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={emailChangeModalConfirm}
>
<FormattedMessage id="confirm" defaultMessage="Confirm" />
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={emailChangeModalCancel}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
</Modal>
)}
<Grid item xs={12} md={12}>
<TabsContainerWrapper>
<Tabs
@ -350,18 +517,20 @@ function User(props: singleUserProps) {
defaultMessage="Apply Changes"
/>
</Button>
<Button
variant="contained"
onClick={handleDelete}
sx={{
marginLeft: '10px'
}}
>
<FormattedMessage
id="delete_user"
defaultMessage="Delete User"
/>
</Button>
<Tooltip title={deleteDisabledReason}>
<span style={{ marginLeft: '10px' }}>
<Button
variant="contained"
onClick={handleDelete}
disabled={cannotDelete}
>
<FormattedMessage
id="delete_user"
defaultMessage="Delete User"
/>
</Button>
</span>
</Tooltip>
{loading && (
<CircularProgress
@ -380,7 +549,11 @@ function User(props: singleUserProps) {
alignItems: 'center'
}}
>
An error has occurred
{errorMsgId ? (
<FormattedMessage id={errorMsgId} />
) : (
<FormattedMessage id="errorOccured" />
)}
<IconButton
color="inherit"
size="small"
@ -400,7 +573,11 @@ function User(props: singleUserProps) {
alignItems: 'center'
}}
>
Successfully updated
{emailChanged ? (
<FormattedMessage id="userEmailChangedSetPasswordSent" />
) : (
<FormattedMessage id="successfullyUpdated" />
)}
<IconButton
color="inherit"
size="small"

View File

@ -5,6 +5,7 @@ import {
Grid,
IconButton,
InputAdornment,
MenuItem,
TextField
} from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
@ -20,8 +21,10 @@ import { UserType } from '../../../interfaces/UserTypes';
function UsersSearch() {
const intl = useIntl();
const [searchTerm, setSearchTerm] = useState('');
const { availableUsers, fetchAvailableUsers } = useContext(AccessContext);
const [filteredData, setFilteredData] = useState(availableUsers);
// 'all' = no role filter; otherwise the selected UserType enum value.
const [roleFilter, setRoleFilter] = useState<UserType | 'all'>('all');
const { allUsers, fetchAllUsers } = useContext(AccessContext);
const [filteredData, setFilteredData] = useState(allUsers);
const [openModal, setOpenModal] = useState(false);
const context = useContext(UserContext);
const [userCreated, setUserCreated] = useState(false);
@ -29,19 +32,21 @@ function UsersSearch() {
const { currentUser } = context;
useEffect(() => {
fetchAvailableUsers();
fetchAllUsers();
}, []);
const fetchDataAgain = () => {
fetchAvailableUsers();
fetchAllUsers();
};
useEffect(() => {
const filtered = availableUsers.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
const filtered = allUsers.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
(roleFilter === 'all' || item.userType === roleFilter)
);
setFilteredData(filtered);
}, [searchTerm, availableUsers]);
}, [searchTerm, roleFilter, allUsers]);
const handleSubmit = () => {
setOpenModal(true);
@ -50,7 +55,7 @@ function UsersSearch() {
setOpenModal(false);
setUserCreated(true);
fetchAvailableUsers();
fetchAllUsers();
setTimeout(() => {
setUserCreated(false);
@ -162,6 +167,36 @@ function UsersSearch() {
/>
</FormControl>
</Grid>
<Grid item xs={12} md={isMobile ? 5 : 3}>
<FormControl variant="outlined" fullWidth>
<TextField
select
label={intl.formatMessage({ id: 'role' })}
value={roleFilter}
onChange={(e) =>
setRoleFilter(
e.target.value === 'all'
? 'all'
: (Number(e.target.value) as UserType)
)
}
fullWidth
>
<MenuItem value="all">
{intl.formatMessage({ id: 'allRoles' })}
</MenuItem>
<MenuItem value={UserType.client}>
{intl.formatMessage({ id: 'roleClient' })}
</MenuItem>
<MenuItem value={UserType.partner}>
{intl.formatMessage({ id: 'rolePartner' })}
</MenuItem>
<MenuItem value={UserType.admin}>
{intl.formatMessage({ id: 'roleAdmin' })}
</MenuItem>
</TextField>
</FormControl>
</Grid>
</Grid>
<FlatUsersView users={filteredData} fetchDataAgain={fetchDataAgain} />
</>

View File

@ -19,6 +19,8 @@ interface AccessContextProviderProps {
accessibleInstallationsForUser: I_Installation[];
availableUsers: InnovEnergyUser[];
fetchAvailableUsers: () => Promise<void>;
allUsers: InnovEnergyUser[];
fetchAllUsers: () => Promise<void>;
usersWithDirectAccess: InnovEnergyUser[];
fetchUsersWithDirectAccessForResource: (
tempresourceType: string,
@ -53,6 +55,10 @@ export const AccessContext = createContext<AccessContextProviderProps>({
fetchAvailableUsers: () => {
return Promise.resolve();
},
allUsers: [],
fetchAllUsers: () => {
return Promise.resolve();
},
usersWithDirectAccess: [],
fetchUsersWithDirectAccessForResource: () => Promise.resolve(),
usersWithInheritedAccess: [],
@ -80,6 +86,7 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
InnovEnergyUser[]
>([]);
const [availableUsers, setAvailableUsers] = useState<InnovEnergyUser[]>([]);
const [allUsers, setAllUsers] = useState<InnovEnergyUser[]>([]);
const [accessibleInstallationsForUser, setAccessibleInstallationsForUser] =
useState<I_Installation[]>([]);
@ -141,6 +148,13 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
});
};
// Admin-only: every user in the system (for the Users management page).
const fetchAllUsers = async (): Promise<void> => {
return axiosConfig.get('/GetAllUsers').then((res) => {
setAllUsers(res.data);
});
};
const RevokeAccessFromResource = useCallback(
async (
resourceType: string,
@ -187,6 +201,8 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
accessibleInstallationsForUser,
availableUsers,
fetchAvailableUsers,
allUsers,
fetchAllUsers,
usersWithDirectAccess,
fetchUsersWithDirectAccessForResource,
usersWithInheritedAccess,

View File

@ -0,0 +1,21 @@
export interface BatteryAnomalySummary {
installationId: number;
lastDetectedAt: string;
lastVoltage: number;
lastBattery: string;
count: number;
}
export interface BatteryAnomalyLogEntry {
id: number;
installationId: number;
batteryLabel: string;
detectedAt: string;
date: string;
dateHour: string;
voltage: number;
soc: number;
createdAt: string;
}
export const VOLTAGE_ANOMALY_PRODUCTS = new Set<number>([2, 5]);

View File

@ -112,18 +112,23 @@ export const transformInputToBatteryViewDataJson = async (
'.Dc.Current'
];
const pathsToSearch = [
'Battery.Devices.1',
'Battery.Devices.2',
'Battery.Devices.3',
'Battery.Devices.4',
'Battery.Devices.5',
'Battery.Devices.6',
'Battery.Devices.7',
'Battery.Devices.8',
'Battery.Devices.9',
'Battery.Devices.10'
];
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
// Built dynamically below from the first JSON chunk; other products use the fixed list.
const pathsToSearch: string[] =
product === 4
? []
: [
'Battery.Devices.1',
'Battery.Devices.2',
'Battery.Devices.3',
'Battery.Devices.4',
'Battery.Devices.5',
'Battery.Devices.6',
'Battery.Devices.7',
'Battery.Devices.8',
'Battery.Devices.9',
'Battery.Devices.10'
];
const pathsToSave = [];
@ -163,7 +168,6 @@ export const transformInputToBatteryViewDataJson = async (
//navigate(routes.login);
}
});
for (var i = 0; i < timestampArray.length; i++) {
timestampPromises.push(
fetchJsonDataForOneTime(
@ -311,21 +315,44 @@ export const transformInputToBatteryViewDataJson = async (
});
});
} else {
// SaliMax, Salidomo, SodistoreMax: existing logic
const battery_nodes =
result.Config.Devices.BatteryNodes.toString().split(',');
//Initialize the chartData structure based on the node names extracted from the first result
// SaliMax, Salidomo, SodistoreMax, SodistoreGrid: existing logic
// SodistoreGrid (product 4) batteries live under ListOfBatteriesRecord[cluster].Devices[id];
// enumerate them dynamically and build pathsToSearch in parallel with pathsToSave.
let old_length = pathsToSave.length;
if (battery_nodes.length > old_length) {
battery_nodes.forEach((node) => {
const node_number =
product == 3 || product == 4 ? Number(node) + 1 : Number(node) - 1;
if (!pathsToSave.includes('Node' + node_number)) {
pathsToSave.push('Node' + node_number);
}
if (product === 4) {
const lobr = (result as any)?.ListOfBatteriesRecord ?? {};
const clusters = Object.keys(lobr).sort(
(a, b) => Number(a) - Number(b)
);
clusters.forEach((clusterId) => {
const devices = Object.keys(lobr[clusterId]?.Devices ?? {}).sort(
(a, b) => Number(a) - Number(b)
);
devices.forEach((deviceId) => {
const nodeName = `Node${clusterId}-${deviceId}`;
if (!pathsToSave.includes(nodeName)) {
pathsToSave.push(nodeName);
pathsToSearch.push(
`ListOfBatteriesRecord.${clusterId}.Devices.${deviceId}`
);
}
});
});
} else {
const battery_nodes =
result.Config.Devices.BatteryNodes.toString().split(',');
//Initialize the chartData structure based on the node names extracted from the first result
if (battery_nodes.length > old_length) {
battery_nodes.forEach((node) => {
const node_number =
product == 3 ? Number(node) + 1 : Number(node) - 1;
if (!pathsToSave.includes('Node' + node_number)) {
pathsToSave.push('Node' + node_number);
}
});
}
}
if (initialiation) {
@ -341,7 +368,7 @@ export const transformInputToBatteryViewDataJson = async (
});
}
if (battery_nodes.length > old_length) {
if (pathsToSave.length > old_length) {
categories.forEach((category) => {
pathsToSave.forEach((path) => {
if (pathsToSave.indexOf(path) >= old_length) {
@ -454,6 +481,23 @@ export const transformInputToDailyDataJson = async (
null, // DCLoad not available for SodioHome
'SODIOHOME_BATTERY_VOLTAGE'
]
: product == 4
? [
// SodistoreGrid: placeholders — actual extraction runs in the
// product===4 switch below; nulls only mark "no data path", so
// the forEach skips the irrelevant categories. Battery voltage
// (index 8) MUST be a non-null placeholder, otherwise the entry
// is skipped before the switch can populate it.
'ListOfBatteriesRecord', // 0 soc
'ListOfBatteriesRecord', // 1 temperature
'ListOfBatteriesRecord', // 2 dcPower (battery power)
null, // 3 gridPower — no grid meter
null, // 4 pvProduction — no PV
'DcDc.Dc.Link.Voltage', // 5 dcBusVoltage
null, // 6 ACLoad — no AC load meter
null, // 7 DCLoad — no DC load meter
'ListOfBatteriesRecord' // 8 batteryVoltage (cluster avg)
]
: [
'Battery.Soc',
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
@ -599,6 +643,46 @@ export const transformInputToDailyDataJson = async (
break;
}
}
} else if (product === 4) {
// SodiStoreGrid: only battery + DC-side metrics exist.
// No grid meter, no PV, no AC/DC load on this product — those series stay empty.
const lobr: Record<string, any> =
(result as any)?.ListOfBatteriesRecord ?? {};
const clusterValues = Object.values(lobr) as any[];
const avgOf = (pick: (c: any) => number | undefined): number | undefined => {
const xs = clusterValues
.map(pick)
.filter((v): v is number => typeof v === 'number');
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined;
};
const sumOf = (pick: (c: any) => number | undefined): number | undefined => {
const xs = clusterValues
.map(pick)
.filter((v): v is number => typeof v === 'number');
return xs.length ? xs.reduce((a, b) => a + b, 0) : undefined;
};
switch (category_index) {
case 0: // soc
value = avgOf((c) => c?.Soc);
break;
case 1: // temperature
value = avgOf((c) => c?.TemperatureCell1);
break;
case 2: // battery power (sum of cluster powers, else DcDc battery-side)
value =
sumOf((c) => c?.Power) ??
(result as any)?.DcDc?.Dc?.Battery?.Power;
break;
case 5: // dc bus voltage
value = (result as any)?.DcDc?.Dc?.Link?.Voltage;
break;
case 8: // battery voltage (cluster average, else DcDc battery side)
value =
avgOf((c) => c?.Voltage) ??
(result as any)?.DcDc?.Dc?.Battery?.Voltage;
break;
// case 3 (grid), 4 (PV), 6 (AC load), 7 (DC load) — not available on SodiStoreGrid
}
} else if (category_index === 4) {
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
if (get(result, path) !== undefined) {
@ -651,6 +735,15 @@ export const transformInputToDailyDataJson = async (
chartOverview[category].magnitude = magnitude;
});
// SodistoreGrid: 18 parallel battery devices easily swing into the kW range.
// Pin Battery Power to at least kW so the axis stays stable across day/night cycles.
if (product === 4) {
chartOverview.dcPower.magnitude = Math.max(
chartOverview.dcPower.magnitude,
1
);
}
chartOverview.soc.unit = '(%)';
chartOverview.soc.min = 0;
chartOverview.soc.max = 100;

View File

@ -23,7 +23,7 @@ export type ChecklistItem = {
updatedAt: string;
};
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]);
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 5]);
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
'checklistStep8Sub1',

View File

@ -28,7 +28,8 @@ export interface I_Installation extends I_S3Credentials {
pvStringsPerInverter: string;
installationModel: string;
externalEms: string;
couplingType: string;
couplingType: string | null;
backupLoad: string | null;
parentId: number;
s3WriteKey: string;

View File

@ -0,0 +1,138 @@
// Installer-facing on-site installation checklist (sodistore home only).
// Distinct from the internal monitor-onboarding checklist (ChecklistTypes.tsx) —
// no assignee, no done-date; per-step substeps + comments + per-installation
// signature ("Bemerkungen" + typed-name e-signature).
export type OnSiteChecklistSubtask =
| { kind: 'task'; text: string; checked: boolean }
| { kind: 'heading'; heading: string };
export type OnSiteChecklistItem = {
id: number;
installationId: number;
stepNumber: number;
stepKey: string;
checked: boolean;
comments: string;
subtasks: string | null;
createdAt: string;
updatedAt: string;
};
export type OnSiteChecklistSignature = {
id: number;
installationId: number;
remarks: string;
signedByName: string | null;
signedByUserId: number | null;
signedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type OnSiteChecklistResponse = {
items: OnSiteChecklistItem[];
signature: OnSiteChecklistSignature;
};
// Static per-step metadata not stored in the DB (title key + image filenames +
// optional warning key). Lookup table keyed by step number. Image paths are
// resolved at render time against the static assets folder.
export type OnSiteStepMeta = {
number: number;
titleKey: string;
warnings?: OnSiteWarning[];
images: string[];
};
// A single "Achtung" callout. severity 'error' renders red (used for the most
// critical warnings that appear in red in the source PDF).
export type OnSiteWarning = {
key: string;
severity: 'warning' | 'error';
};
export const ON_SITE_STEP_META: ReadonlyArray<OnSiteStepMeta> = [
{
number: 1,
titleKey: 'onSiteStep1Title',
warnings: [{ key: 'onSiteStep1Warning', severity: 'warning' }],
images: ['step1-auspacken.jpg']
},
{
number: 2,
titleKey: 'onSiteStep2Title',
images: ['step2-wr-montiert.jpg', 'step2-ac-anschluesse.jpg', 'step2-ac-stecker.jpg']
},
{
number: 3,
titleKey: 'onSiteStep3Title',
warnings: [{ key: 'onSiteStep3Warning', severity: 'warning' }],
images: ['step3-hauptverteilung.jpg', 'step3-hauptverteilung-2.jpg']
},
{
number: 4,
titleKey: 'onSiteStep4Title',
warnings: [{ key: 'onSiteStep4Warning', severity: 'warning' }],
images: ['step4-meter.jpg', 'step4-wandler.jpg']
},
{
number: 5,
titleKey: 'onSiteStep5Title',
warnings: [{ key: 'onSiteStep5Warning', severity: 'warning' }],
images: ['step5-gateway.jpg']
},
{
number: 6,
titleKey: 'onSiteStep6Title',
warnings: [
{ key: 'onSiteStep6Warning1', severity: 'warning' },
{ key: 'onSiteStep6Warning2', severity: 'error' },
{ key: 'onSiteStep6Warning3', severity: 'warning' },
{ key: 'onSiteStep6Warning4', severity: 'warning' }
],
images: ['step6-batterien.jpg', 'step6-splitter.jpg']
},
{
number: 7,
titleKey: 'onSiteStep7Title',
warnings: [{ key: 'onSiteStep7Warning', severity: 'warning' }],
images: ['step7-pv-switch.jpg']
},
{
number: 8,
titleKey: 'onSiteStep8Title',
images: ['step8-batterie-breaker.jpg']
}
];
export function parseOnSiteSubtasks(raw: string | null | undefined): OnSiteChecklistSubtask[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.map((x): OnSiteChecklistSubtask | null => {
if (!x || typeof x !== 'object') return null;
if (typeof x.heading === 'string') {
return { kind: 'heading', heading: String(x.heading) };
}
if (typeof x.text === 'string') {
return { kind: 'task', text: String(x.text), checked: Boolean(x.checked) };
}
return null;
})
.filter((x): x is OnSiteChecklistSubtask => x !== null);
} catch {
return [];
}
}
export function serializeOnSiteSubtasks(subtasks: OnSiteChecklistSubtask[]): string {
return JSON.stringify(
subtasks.map((s) => {
if (s.kind === 'heading') return { heading: s.heading };
return { text: s.text, checked: s.checked };
})
);
}

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Alarmbericht",
"exportCsv": "CSV exportieren",
"refresh": "Aktualisieren",
"alarmReportRetentionNote": "Zeiten in Schweizer Zeit (MEZ/MESZ). Es werden die aktuell gespeicherten Alarme angezeigt bis zu den letzten 100 Ereignissen pro Anlage.",
"filterSeverity": "Schweregrad",
"severityBoth": "Fehler & Warnungen",
"severityErrors": "Nur Fehler",
"severityWarnings": "Nur Warnungen",
"filterProduct": "Produkt",
"filterDevice": "Gerät",
"filterInstallation": "Anlage",
"filterAllInstallations": "Alle Anlagen",
"filterTimeRange": "Zeitraum",
"rangeAll": "Alle gespeicherten",
"rangeToday": "Heute",
"rangeLast7": "Letzte 7 Tage",
"rangeThisMonth": "Dieser Monat",
"rangeLastMonth": "Letzter Monat",
"rangeCustom": "Benutzerdefiniert…",
"rangeFrom": "Von",
"rangeTo": "Bis",
"filterSearch": "Suche",
"filterView": "Ansicht",
"viewGrouped": "Gruppiert",
"viewRaw": "Einzelereignisse",
"alarmReportLoadError": "Alarmbericht konnte nicht geladen werden.",
"alarmReportGroupCount": "{groups} verschiedene Alarme · {events} Ereignisse",
"alarmReportEventCount": "{events} Ereignisse",
"colInstallation": "Anlage",
"colDevice": "Gerät",
"colAlarm": "Alarm",
"colSeverity": "Typ",
"colCount": "Anzahl",
"colFirstSeen": "Zuerst gesehen (CH)",
"colLastSeen": "Zuletzt gesehen (CH)",
"colDate": "Datum",
"colTime": "Zeit (CH)",
"alarmReportNoResults": "Keine Alarme entsprechen den ausgewählten Filtern.",
"information": "Information",
"addNewChild": "Neues Kind hinzufügen",
"addNewDialogButton": "Neue Dialogschaltfläche hinzufügen",
@ -96,6 +134,12 @@
"couplingType": "AC/DC-Kopplung",
"couplingAC": "AC-gekoppelt",
"couplingDC": "DC-gekoppelt",
"couplingMixed": "Gemischt",
"backupLoad": "Notstromfähigkeit",
"backupWhole": "Komplettes Haus",
"backupPartial": "Teilnetz",
"backupNone": "nicht verwendet",
"notSet": "Nicht festgelegt",
"selectModel": "Modell auswählen...",
"inverterN": "Wechselrichter {n}",
"clusterN": "Cluster {n}",
@ -114,6 +158,10 @@
"errorOccured": "Ein Fehler ist aufgetreten",
"successfullyUpdated": "Erfolgreich aktualisiert",
"grantAccess": "Zugriff gewähren",
"viewInstallations": "Anlagen anzeigen",
"noInstallationsInFolder": "Keine Anlagen in diesem Ordner",
"couldNotLoadInstallations": "Anlagen konnten nicht geladen werden",
"retry": "Erneut versuchen",
"UserswithDirectAccess": "Benutzer mit direktem Zugriff",
"UserswithInheritedAccess": "Benutzer mit vererbtem Zugriff",
"noerrors": "Keine Fehler",
@ -134,6 +182,7 @@
"unableToLoadData": "Daten können nicht geladen werden",
"unableToRevokeAccess": "Der Zugriff konnte nicht widerrufen werden",
"revokedAccessFromUser": "Zugriff vom Benutzer widerrufen",
"confirmRevokeAccess": "Möchten Sie den Zugriff auf \"{name}\" entfernen?",
"Show Errors": "Fehler anzeigen",
"Show Warnings": "Warnungen anzeigen",
"lastSeen": "Zuletzt gesehen",
@ -483,6 +532,11 @@
"stopTimeMustBeLater": "Die Stoppzeit muss nach der Startzeit liegen",
"signIn": "Anmelden",
"username": "Benutzername",
"role": "Rolle",
"allRoles": "Alle Rollen",
"roleClient": "Kunde",
"rolePartner": "Partner",
"roleAdmin": "Admin",
"password": "Passwort",
"rememberMe": "Angemeldet bleiben",
"login": "Anmelden",
@ -490,6 +544,13 @@
"forgotPasswordLink": "Passwort vergessen?",
"provideYourUsername": "Geben Sie Ihren Benutzernamen ein",
"userName": "Benutzername",
"userEmailChangedSetPasswordSent": "E-Mail aktualisiert. Eine E-Mail zum Festlegen des Passworts wurde an die neue Adresse gesendet, die bisherige Adresse wurde benachrichtigt.",
"userEmailChangeFailedReverted": "Die E-Mail zum Festlegen des Passworts konnte nicht gesendet werden — die Änderung wurde rückgängig gemacht.",
"userEmailChangeNotAllowedOrTaken": "Die E-Mail konnte nicht geändert werden — sie wird bereits von einem anderen Benutzer verwendet oder ist nicht zulässig.",
"invalidEmailFormat": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"emailChangeConfirmTitle": "E-Mail-Adresse dieses Benutzers ändern?",
"emailChangeConfirmWarning": "Dadurch wird das Passwort des Benutzers zurückgesetzt und sowohl die neue als auch die bisherige Adresse benachrichtigt. Der Benutzer muss über den an die neue Adresse gesendeten Link ein neues Passwort festlegen.",
"confirm": "Bestätigen",
"resetPassword": "Passwort zurücksetzen",
"setNewPassword": "Neues Passwort setzen",
"verifyPassword": "Passwort bestätigen",
@ -501,6 +562,8 @@
"connectingToDevice": "Verbindung zum Gerät wird hergestellt...",
"fetchingData": "Daten werden abgerufen...",
"confirmDeleteUser": "Möchten Sie diesen Benutzer löschen?",
"cannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
"cannotDeleteMainAdmin": "Das Hauptadministrator-Konto kann nicht gelöscht werden",
"accessManagement": "Zugriffsverwaltung",
"power": "Leistung",
"voltage": "Spannung",
@ -535,6 +598,16 @@
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
"installationSetup": "Installationseinrichtung",
"batteryLimits": "Batteriegrenzwerte",
"relayControl": "Relaissteuerung",
"relay1": "Relais 1",
"relay2": "Relais 2",
"relay3": "Relais 3",
"relay4": "Relais 4",
"digitalInputs": "Digitaleingänge",
"digitalInput1": "Digitaleingang 1",
"digitalInput2": "Digitaleingang 2",
"digitalInput3": "Digitaleingang 3",
"digitalInput4": "Digitaleingang 4",
"systemSettings": "Systemeinstellungen",
"pvPerInverter": "PV pro Wechselrichter",
"pvInInverter": "PV in Wechselrichter {number}",
@ -544,6 +617,9 @@
"dynamicPricingSpotPrice": "Spot-Preis",
"dynamicPricingTou": "TOU",
"currentPrice": "Aktueller Preis",
"currentPriceHistory": "Aktueller Preis (letzte 7 Tage)",
"currentPriceNoData": "Keine Preishistorie für die letzten 7 Tage verfügbar.",
"resetZoom": "Zoom zurücksetzen",
"priceToSell": "Verkaufspreis",
"priceToBuy": "Kaufpreis",
"timeToSell": "Verkaufszeit",
@ -739,7 +815,7 @@
"invalidFileType": "Ungültiger Dateityp.",
"uploadFailed": "Hochladen fehlgeschlagen.",
"uploadSuccess": "Hochladen erfolgreich.",
"checklist": "Checkliste",
"checklist": "Monitor-Onboarding-Checkliste",
"checklistTitle": "Schritte zur Anbindung der Installation an Monitor",
"checklistProgress": "Fortschritt: {done}/{total} ({percent}%)",
"checklistStep": "Schritt",
@ -771,10 +847,8 @@
"checklistStep11": "Software vor Ort verifiziert",
"checklistStep12": "Installation online auf Monitor",
"checklistStep13": "Kunde über Monitor-Konto und Reports informiert",
"checklistStep14": "Benutzerkonto mit richtigen Ordnern und Zugriffen erstellt",
"checklistStep15": "Kundennachverfolgung abgeschlossen, Feedback eingeholt",
"checklistStep16": "Weitere Anliegen werden über das Ticket-System verfolgt",
"checklistStep3Sub1": "Installations-Seriennummer",
"checklistStep3Sub2": "Benutzerkonto mit richtigen Ordnern und Zugriffen erstellt",
"checklistStep5Sub1": "Kundeninformationen (E-Mail, Adresse)",
"checklistStep5Sub2": "Installationsinformationen (externes EMS, Stromanbieter, Datenerfassung)",
"checklistStep5Sub3": "Batterie-Seriennummer",
@ -794,10 +868,107 @@
"checklistStep10Sub2": "Zeit- und Materialbericht in Monitoring hochgeladen",
"checklistStep10Sub3": "Atef kontaktieren, falls externes EMS vorhanden ist",
"checklistNoAttachments": "Noch keine Datei angehängt.",
"setupProgress": "Setup-Fortschritt",
"setupProgress": "Monitor-Onboarding-Fortschritt",
"batteryVoltageAnomaly": "Voll bei Unterspannung",
"voltageAnomalyLogTitle": "Protokoll: voll bei Unterspannung",
"voltageAnomalyTime": "Zeit",
"noVoltageAnomalies": "Keine Anomalien erfasst",
"checklistPhaseEmpty": "Nicht gestartet",
"checklistPhasePreparation": "Vorbereitung",
"checklistPhaseOnSite": "Vor Ort",
"checklistPhaseHandover": "Kundenübergabe",
"checklistPhaseComplete": "Abgeschlossen"
"checklistPhaseComplete": "Abgeschlossen",
"activePowerPercent": "Wirkleistung (%)",
"minDischargeVoltageV": "Min. Entladespannung (V)",
"maxDischargeCurrentA": "Max. Entladestrom (A)",
"maxChargeCurrentA": "Max. Ladestrom (A)",
"maxChargeVoltageV": "Max. Ladespannung (V)",
"onSiteChecklist": "Vor-Ort-Checkliste",
"onSiteChecklistTitle": "Vor-Ort-Installations-Checkliste",
"onSiteChecklistProgress": "Fortschritt: {done}/{total} ({percent}%)",
"onSiteStepHeading": "Schritt",
"onSiteImagesHeading": "Bilder zur Installation",
"onSiteCommentsPlaceholder": "Notizen, Beobachtungen…",
"onSiteSaveFailed": "Änderung konnte nicht gespeichert werden",
"onSiteLocked": "Diese Checkliste wurde unterzeichnet und ist gesperrt.",
"onSiteRemarksTitle": "Bemerkungen vom Installateur",
"onSiteRemarksPlaceholder": "Zusätzliche Bemerkungen…",
"onSiteSignatureTitle": "E-Signatur",
"onSiteFullName": "Vollständiger Name",
"onSiteSignButton": "Unterschreiben",
"onSiteSignedBy": "Unterzeichnet von {name} am {date}",
"onSiteSignedSuccess": "Checkliste erfolgreich unterzeichnet",
"onSiteResetSignature": "Unterschrift zurücksetzen",
"onSiteShowMore": "Mehr anzeigen",
"onSiteShowLess": "Weniger anzeigen",
"onSiteConsentShort": "Ich bestätige, dass die Installation gemäss obiger Checkliste fachgerecht ausgeführt und in Betrieb genommen wurde.",
"onSiteConsentLong": "Mit der elektronischen Unterschrift bestätigt der ausführende Installateur, dass die Installation gemäss obiger Checkliste und den anerkannten Regeln der Technik ausgeführt und in Betrieb genommen wurde. Die obenstehenden Angaben sind vollständig und wahrheitsgetreu. Diese Bestätigung ist Teil der Inbetriebnahme und wird zusammen mit der Checkliste an inesco energy übermittelt.",
"onSiteStep1Title": "Auspacken",
"onSiteStep1Sub1": "sodistore home vorsichtig auspacken",
"onSiteStep1Sub2": "Standortbedingungen prüfen (Trockene Umgebung 20 bis +55°C, feste Ebene)",
"onSiteStep1Warning": "Wenn Transportschäden vorhanden sind, bitte mit Fotos dokumentieren und inesco melden.",
"onSiteStep2Title": "inesco Wechselrichter montieren",
"onSiteStep2Sub1": "WR wieder an der Vorrichtung montieren",
"onSiteStep2Sub2": "Überprüfen ob durch Transport Kabel beschädigt wurden",
"onSiteStep2Sub3": "AC-Kabel wieder einstecken GRID zu GRID und BACK-UP zu Load",
"onSiteStep3Title": "Arbeiten in der Hauptverteilung AC-seitig",
"onSiteStep3Sub1": "AC-Installation gemäss Schema installieren",
"onSiteStep3Sub2": "Netzumschalter der inesco (empfohlen) installieren",
"onSiteStep3Sub3": "LS für WR installieren je nach Modell 25A/40A",
"onSiteStep3Sub4": "Anlageschalter für sodistore home installieren",
"onSiteStep3Sub5": "Zul. Kabel (Grid) auf sodistore home anschliessen",
"onSiteStep3Sub6": "Load Kabel auf sodistore home anschliessen",
"onSiteStep3Subheading1": "Notstromanforderungen",
"onSiteStep3Sub7": "Notstromversorgte Gruppen in der HV definieren",
"onSiteStep3Sub8": "Achtung TNS oder TNC-Gruppen dürfen nicht gemischt werden!!!",
"onSiteStep3Sub9": "WR muss je nach System TNS oder TNC eingestellt werden",
"onSiteStep3Sub10": "Notstromgruppen mit Typ LS Typ B und 300mA FI absichern",
"onSiteStep3Warning": "Die inesco energy empfiehlt einen Netzumschalter beim Notstrom Betrieb. So kann im Störungsfall die Anlage auf Netz umgeschaltet und das Haus trotzdem mit Energie versorgt werden.",
"onSiteStep4Title": "Anschliessen des Meters",
"onSiteStep4Sub1": "Einbau des Meters in der HV oder im Sodistore home",
"onSiteStep4Sub2": "LS/3LN installieren für Referenzspannung Meter",
"onSiteStep4Sub3": "Wandler um die Zuleitung klemmen, beachten der Richtigen Richtung",
"onSiteStep4Sub4": "Bus-Kabel von Meter auf WR installieren und einstecken, dies darf verlängert werden",
"onSiteStep4Warning": "Die Messwandler-Kabel dürfen nicht verlängert oder gekürzt werden, da dies zu falschen Messungen führt. Die Wandler sollten im Hochvoltbereich einen Mindestabstand von 15 cm zu anderen Wandlern oder hochfrequenten Signalen haben, da sonst ebenfalls falsche Messwerte auftreten können.",
"onSiteStep5Title": "inesco Gateway und Internet",
"onSiteStep5Sub1": "Internetkabel zu inesco Gateway ziehen",
"onSiteStep5Sub2": "Verbindung inesco Gateway zu WR-Prüfen (USB zu BMS)",
"onSiteStep5Sub3": "inesco Gateway mit Spannung versorgen",
"onSiteStep5Sub4": "WLAN-Verbindung zu WR herstellen",
"onSiteStep5Warning": "Die WR brauchen zwingend WLAN-Verbindung kein RJ45 LAN, ansonsten können diese nicht kommunizieren.",
"onSiteStep6Title": "Systemverbindungen",
"onSiteStep6Sub1": "Erdverbindungen",
"onSiteStep6Sub2": "inesco Gateway (USB) mit WR verbunden (über Splitter)",
"onSiteStep6Sub3": "Batterien + / - zu WR gemäss Schema",
"onSiteStep6Sub4": "CAN von Batterie zu WR auf BMS (über Splitter)",
"onSiteStep6Sub5": "Batterien abschlaufen RS485 2 zu RS485 2",
"onSiteStep6Sub6": "Von Splitter auf WR",
"onSiteStep6Sub7": "bei 27/36 müssen die WR über PARA verbunden werden",
"onSiteStep6Warning1": "Systemverbindungen müssen gemäss Schema verdrahtet werden. Jede Baugruppe (9, 18, 27 und 36) hat ein eigenes Schema, das zu beachten ist.",
"onSiteStep6Warning2": "Die RJ45-Kabel dürfen nicht vertauscht werden. Das schwarze Kabel ist mit Kontakt A verbunden und führt zum inesco Gateway über USB. Kontakt B ist mit der Batterie verbunden.",
"onSiteStep6Warning3": "Die Kabel haben unterschiedliche Pinbelegungen und sind entsprechend gekrimpt. Daher dürfen sie nicht vertauscht werden.",
"onSiteStep6Warning4": "Maximum 2 Batterien + / - abschlaufen.",
"onSiteStep7Title": "Anschluss PV-Wechselrichter",
"onSiteStep7Sub1": "DC-Leitungen auf inesco WR anschliessen. Beachten der String Spannungen.",
"onSiteStep7Warning": "Wenn die Anlage AC-gekoppelt ist, bleibt der bestehende WR und der inesco WR wird nicht mit den DC-Leitungen der PV-Anlage verbunden.",
"onSiteStep8Title": "Inbetriebnahme",
"onSiteStep8Sub1": "IBN via Telefon mit inesco",
"onSiteStep8Sub2": "Sichtprüfung",
"onSiteStep8Sub3": "Messungen nach Norm DC und AC",
"onSiteStep8Sub4": "Spannung zuschalten",
"onSiteStep8Sub5": "DC-Schalter beim WR einschalten",
"onSiteStep8Sub6": "Batterien einschalten Knopf auf ON stellen",
"onSiteStep8Sub7": "Breaker an den Batterien auf ON stellen",
"onSiteStep8Sub8": "Prüfen, ob alle Geräte angezeigt werden (WR, Batterie, inesco Gateway)",
"onSiteStep8Sub9": "Anlage via ESS Link App eröffnen",
"onSiteStep8Sub10": "Konfigurieren für den Kunden via ESS Link App",
"onSiteStep8Sub11": "EVU-Zähler vergleichen (gleiche kW-Werte wie im Portal)"
}

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Alarm Report",
"exportCsv": "Export CSV",
"refresh": "Refresh",
"alarmReportRetentionNote": "Times shown in Swiss time (CET/CEST). Showing currently retained alarms — up to the last 100 events per installation.",
"filterSeverity": "Severity",
"severityBoth": "Errors & Warnings",
"severityErrors": "Errors only",
"severityWarnings": "Warnings only",
"filterProduct": "Product",
"filterDevice": "Device",
"filterInstallation": "Installation",
"filterAllInstallations": "All installations",
"filterTimeRange": "Time range",
"rangeAll": "All retained",
"rangeToday": "Today",
"rangeLast7": "Past 7 days",
"rangeThisMonth": "This month",
"rangeLastMonth": "Last month",
"rangeCustom": "Custom…",
"rangeFrom": "From",
"rangeTo": "To",
"filterSearch": "Search",
"filterView": "View",
"viewGrouped": "Grouped",
"viewRaw": "Raw events",
"alarmReportLoadError": "Failed to load the alarm report.",
"alarmReportGroupCount": "{groups} distinct alarms · {events} events",
"alarmReportEventCount": "{events} events",
"colInstallation": "Installation",
"colDevice": "Device",
"colAlarm": "Alarm",
"colSeverity": "Type",
"colCount": "Count",
"colFirstSeen": "First seen (CH)",
"colLastSeen": "Last seen (CH)",
"colDate": "Date",
"colTime": "Time (CH)",
"alarmReportNoResults": "No alarms match the selected filters.",
"allInstallations": "All installations",
"applyChanges": "Apply changes",
"country": "Country",
@ -78,6 +116,12 @@
"couplingType": "AC/DC Coupling",
"couplingAC": "AC-coupled",
"couplingDC": "DC-coupled",
"couplingMixed": "Mixed",
"backupLoad": "Backup Load",
"backupWhole": "Whole house",
"backupPartial": "Partial",
"backupNone": "Not used",
"notSet": "Not set",
"selectModel": "Select model...",
"inverterN": "Inverter {n}",
"clusterN": "Cluster {n}",
@ -96,6 +140,10 @@
"errorOccured": "An error has occurred",
"successfullyUpdated": "Successfully updated",
"grantAccess": "Grant Access",
"viewInstallations": "View installations",
"noInstallationsInFolder": "No installations in this folder",
"couldNotLoadInstallations": "Could not load installations",
"retry": "Retry",
"UserswithDirectAccess": "Users with Direct Access",
"UserswithInheritedAccess": "Users with Inherited Access",
"noerrors": "There are no errors",
@ -116,6 +164,7 @@
"unableToLoadData": "Unable to load data",
"unableToRevokeAccess": "Unable to revoke access",
"revokedAccessFromUser": "Revoked access from user: ",
"confirmRevokeAccess": "Do you want to remove access to \"{name}\"?",
"Show Errors": "Show Errors",
"Show Warnings": "Show Warnings",
"lastSeen": "Last seen",
@ -231,6 +280,11 @@
"stopTimeMustBeLater": "Stop time must be later than start time",
"signIn": "Sign in",
"username": "Username",
"role": "Role",
"allRoles": "All roles",
"roleClient": "Client",
"rolePartner": "Partner",
"roleAdmin": "Admin",
"password": "Password",
"rememberMe": "Remember me",
"login": "Login",
@ -238,6 +292,13 @@
"forgotPasswordLink": "Forgot password?",
"provideYourUsername": "Provide your username",
"userName": "User Name",
"userEmailChangedSetPasswordSent": "Email updated. A set-password email was sent to the new address, and the previous address was notified.",
"userEmailChangeFailedReverted": "Couldn't send the set-password email — the email change was reverted.",
"userEmailChangeNotAllowedOrTaken": "Couldn't change the email — it is already in use by another user or not permitted.",
"invalidEmailFormat": "Please enter a valid email address.",
"emailChangeConfirmTitle": "Change this user's email?",
"emailChangeConfirmWarning": "This resets the user's password and emails both the new and the previous address. The user must set a new password via the link sent to the new address.",
"confirm": "Confirm",
"resetPassword": "Reset Password",
"setNewPassword": "Set New Password",
"verifyPassword": "Verify Password",
@ -249,6 +310,8 @@
"connectingToDevice": "Connecting to the device...",
"fetchingData": "Fetching data...",
"confirmDeleteUser": "Do you want to delete this user?",
"cannotDeleteSelf": "You cannot delete your own account",
"cannotDeleteMainAdmin": "The main admin account cannot be deleted",
"accessManagement": "Access Management",
"power": "Power",
"voltage": "Voltage",
@ -283,6 +346,16 @@
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
"installationSetup": "Installation Setup",
"batteryLimits": "Battery Limits",
"relayControl": "Relay Control",
"relay1": "Relay 1",
"relay2": "Relay 2",
"relay3": "Relay 3",
"relay4": "Relay 4",
"digitalInputs": "Digital Inputs",
"digitalInput1": "Digital Input 1",
"digitalInput2": "Digital Input 2",
"digitalInput3": "Digital Input 3",
"digitalInput4": "Digital Input 4",
"systemSettings": "System Settings",
"pvPerInverter": "PV per Inverter",
"pvInInverter": "PV in Inverter {number}",
@ -292,6 +365,9 @@
"dynamicPricingSpotPrice": "Spot Price",
"dynamicPricingTou": "TOU",
"currentPrice": "Current Price",
"currentPriceHistory": "Current Price (last 7 days)",
"currentPriceNoData": "No price history available for the last 7 days.",
"resetZoom": "Reset zoom",
"priceToSell": "Price to Sell",
"priceToBuy": "Price to Buy",
"timeToSell": "Time to Sell",
@ -487,7 +563,7 @@
"invalidFileType": "Invalid file type.",
"uploadFailed": "Upload failed.",
"uploadSuccess": "Upload successful.",
"checklist": "Checklist",
"checklist": "Monitor Onboarding Checklist",
"checklistTitle": "Steps to Bring Installation to Monitor",
"checklistProgress": "Progress: {done}/{total} ({percent}%)",
"checklistStep": "Step",
@ -519,10 +595,8 @@
"checklistStep11": "Software verified on site",
"checklistStep12": "Installation online on Monitor",
"checklistStep13": "Customer informed about Monitor account and reports",
"checklistStep14": "User account created with correct folders and access",
"checklistStep15": "Customer follow-up completed, feedback collected",
"checklistStep16": "Further issues tracked via Ticket system",
"checklistStep3Sub1": "Installation serial number",
"checklistStep3Sub2": "User account created with correct folders and access",
"checklistStep5Sub1": "Customer information (email, address)",
"checklistStep5Sub2": "Installation information (external EMS, grid provider, data collection)",
"checklistStep5Sub3": "Battery serial number",
@ -542,10 +616,107 @@
"checklistStep10Sub2": "Time and material report uploaded to Monitoring",
"checklistStep10Sub3": "Contact Atef if there is external EMS",
"checklistNoAttachments": "No file attached yet.",
"setupProgress": "Setup Progress",
"setupProgress": "Monitor Onboarding Progress",
"batteryVoltageAnomaly": "Full @ low voltage",
"voltageAnomalyLogTitle": "Full-charge low-voltage log",
"voltageAnomalyTime": "Time",
"noVoltageAnomalies": "No anomalies recorded",
"checklistPhaseEmpty": "Not started",
"checklistPhasePreparation": "Preparation",
"checklistPhaseOnSite": "On-site",
"checklistPhaseHandover": "Customer handover",
"checklistPhaseComplete": "Complete"
"checklistPhaseComplete": "Complete",
"activePowerPercent": "Active Power (%)",
"minDischargeVoltageV": "Min Discharge Voltage (V)",
"maxDischargeCurrentA": "Max Discharge Current (A)",
"maxChargeCurrentA": "Max Charge Current (A)",
"maxChargeVoltageV": "Max Charge Voltage (V)",
"onSiteChecklist": "On-Site Checklist",
"onSiteChecklistTitle": "On-Site Installation Checklist",
"onSiteChecklistProgress": "Progress: {done}/{total} ({percent}%)",
"onSiteStepHeading": "Step",
"onSiteImagesHeading": "Installation pictures",
"onSiteCommentsPlaceholder": "Notes, observations…",
"onSiteSaveFailed": "Could not save change",
"onSiteLocked": "This checklist has been signed and is locked.",
"onSiteRemarksTitle": "Installer remarks",
"onSiteRemarksPlaceholder": "Additional remarks…",
"onSiteSignatureTitle": "E-signature",
"onSiteFullName": "Full name",
"onSiteSignButton": "Sign",
"onSiteSignedBy": "Signed by {name} on {date}",
"onSiteSignedSuccess": "Checklist signed successfully",
"onSiteResetSignature": "Reset signature",
"onSiteShowMore": "Show more",
"onSiteShowLess": "Show less",
"onSiteConsentShort": "I confirm that the installation has been carried out and commissioned in accordance with the checklist above.",
"onSiteConsentLong": "By signing electronically, the executing installer confirms that the installation has been carried out and commissioned in accordance with the checklist above and the recognised rules of technology. The information above is complete and truthful. This confirmation is part of the commissioning and is submitted together with the checklist to inesco energy.",
"onSiteStep1Title": "Unpacking",
"onSiteStep1Sub1": "Carefully unpack sodistore home",
"onSiteStep1Sub2": "Check site conditions (dry environment 20 to +55°C, flat surface)",
"onSiteStep1Warning": "If there is transport damage, please document it with photos and report it to inesco.",
"onSiteStep2Title": "Mount the inesco inverter",
"onSiteStep2Sub1": "Re-mount the inverter on the bracket",
"onSiteStep2Sub2": "Verify cables have not been damaged during transport",
"onSiteStep2Sub3": "Re-connect AC cable: GRID to GRID and BACK-UP to Load",
"onSiteStep3Title": "Work in the main distribution board (AC side)",
"onSiteStep3Sub1": "Install AC according to the schematic",
"onSiteStep3Sub2": "Install the inesco transfer switch (recommended)",
"onSiteStep3Sub3": "Install the inverter MCB (25A or 40A depending on model)",
"onSiteStep3Sub4": "Install the system breaker for sodistore home",
"onSiteStep3Sub5": "Connect the supply (grid) cable to sodistore home",
"onSiteStep3Sub6": "Connect the load cable to sodistore home",
"onSiteStep3Subheading1": "Backup power requirements",
"onSiteStep3Sub7": "Define backup-supplied groups in the main distribution",
"onSiteStep3Sub8": "Caution: TNS and TNC groups must not be mixed!!!",
"onSiteStep3Sub9": "Inverter must be set to TNS or TNC depending on the system",
"onSiteStep3Sub10": "Protect backup groups with Type B MCB and 300mA RCD",
"onSiteStep3Warning": "inesco energy recommends a transfer switch when running on backup. In a fault scenario the system can be switched to grid and the house can still be supplied with energy.",
"onSiteStep4Title": "Connect the meter",
"onSiteStep4Sub1": "Install the meter in the main distribution or inside sodistore home",
"onSiteStep4Sub2": "Install MCB / 3LN for the meter reference voltage",
"onSiteStep4Sub3": "Clamp the CTs around the supply line — observe the correct direction",
"onSiteStep4Sub4": "Connect the bus cable from meter to inverter (extension allowed)",
"onSiteStep4Warning": "The CT cables must not be extended or shortened — this leads to incorrect measurements. CTs should keep at least 15 cm distance from other CTs or high-frequency signals in the high-voltage area, otherwise measurements may also be wrong.",
"onSiteStep5Title": "inesco Gateway and internet",
"onSiteStep5Sub1": "Run an internet cable to the inesco Gateway",
"onSiteStep5Sub2": "Verify inesco Gateway connection to inverter (USB to BMS)",
"onSiteStep5Sub3": "Power up the inesco Gateway",
"onSiteStep5Sub4": "Establish WiFi connection to the inverter",
"onSiteStep5Warning": "Inverters strictly require a WiFi connection — no RJ45 LAN — otherwise they cannot communicate.",
"onSiteStep6Title": "System connections",
"onSiteStep6Sub1": "Earth connections",
"onSiteStep6Sub2": "inesco Gateway (USB) connected to inverter (via splitter)",
"onSiteStep6Sub3": "Batteries +/- to inverter according to schematic",
"onSiteStep6Sub4": "CAN from battery to inverter on BMS (via splitter)",
"onSiteStep6Sub5": "Daisy-chain batteries RS485 2 to RS485 2",
"onSiteStep6Sub6": "From splitter to inverter",
"onSiteStep6Sub7": "For 27/36 assemblies the inverters must be connected via PARA",
"onSiteStep6Warning1": "System connections must be wired according to the schematic. Each assembly (9, 18, 27 and 36) has its own schematic that must be followed.",
"onSiteStep6Warning2": "The RJ45 cables must not be swapped. The black cable is connected to contact A and runs to the inesco Gateway via USB. Contact B is connected to the battery.",
"onSiteStep6Warning3": "The cables have different pinouts and are crimped accordingly — they must not be swapped.",
"onSiteStep6Warning4": "Daisy-chain a maximum of 2 batteries +/-.",
"onSiteStep7Title": "PV inverter connection",
"onSiteStep7Sub1": "Connect DC lines to the inesco inverter — observe the string voltages.",
"onSiteStep7Warning": "If the system is AC-coupled, the existing inverter remains and the inesco inverter is not connected to the DC lines of the PV system.",
"onSiteStep8Title": "Commissioning",
"onSiteStep8Sub1": "Commission via phone with inesco",
"onSiteStep8Sub2": "Visual inspection",
"onSiteStep8Sub3": "Measurements according to standard — DC and AC",
"onSiteStep8Sub4": "Switch on voltage",
"onSiteStep8Sub5": "Switch on the DC switch on the inverter",
"onSiteStep8Sub6": "Switch on batteries — set the button to ON",
"onSiteStep8Sub7": "Set the breaker on the batteries to ON",
"onSiteStep8Sub8": "Check that all devices are shown (inverter, battery, inesco Gateway)",
"onSiteStep8Sub9": "Open the system via the ESS Link app",
"onSiteStep8Sub10": "Configure for the customer via the ESS Link app",
"onSiteStep8Sub11": "Compare utility meter (same kW values as on the portal)"
}

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Rapport d'alarmes",
"exportCsv": "Exporter CSV",
"refresh": "Actualiser",
"alarmReportRetentionNote": "Heures affichées en heure suisse (CET/CEST). Affiche les alarmes actuellement conservées — jusqu'aux 100 derniers événements par installation.",
"filterSeverity": "Gravité",
"severityBoth": "Erreurs et avertissements",
"severityErrors": "Erreurs uniquement",
"severityWarnings": "Avertissements uniquement",
"filterProduct": "Produit",
"filterDevice": "Appareil",
"filterInstallation": "Installation",
"filterAllInstallations": "Toutes les installations",
"filterTimeRange": "Période",
"rangeAll": "Tout le conservé",
"rangeToday": "Aujourd'hui",
"rangeLast7": "7 derniers jours",
"rangeThisMonth": "Ce mois-ci",
"rangeLastMonth": "Mois dernier",
"rangeCustom": "Personnalisé…",
"rangeFrom": "Du",
"rangeTo": "Au",
"filterSearch": "Recherche",
"filterView": "Affichage",
"viewGrouped": "Groupé",
"viewRaw": "Événements bruts",
"alarmReportLoadError": "Échec du chargement du rapport d'alarmes.",
"alarmReportGroupCount": "{groups} alarmes distinctes · {events} événements",
"alarmReportEventCount": "{events} événements",
"colInstallation": "Installation",
"colDevice": "Appareil",
"colAlarm": "Alarme",
"colSeverity": "Type",
"colCount": "Nombre",
"colFirstSeen": "Première occurrence (CH)",
"colLastSeen": "Dernière occurrence (CH)",
"colDate": "Date",
"colTime": "Heure (CH)",
"alarmReportNoResults": "Aucune alarme ne correspond aux filtres sélectionnés.",
"information": "Information",
"addUser": "Créer un utilisateur",
"alarms": "Alarmes",
@ -90,6 +128,12 @@
"couplingType": "Couplage AC/DC",
"couplingAC": "Couplage AC",
"couplingDC": "Couplage DC",
"couplingMixed": "Mixte",
"backupLoad": "Capacité de secours",
"backupWhole": "Maison entière",
"backupPartial": "Partiel",
"backupNone": "Non utilisé",
"notSet": "Non défini",
"selectModel": "Sélectionner le modèle...",
"inverterN": "Onduleur {n}",
"clusterN": "Cluster {n}",
@ -108,6 +152,10 @@
"errorOccured": "Une erreur s'est produite",
"successfullyUpdated": "Mise à jour réussie",
"grantAccess": "Accorder l'accès",
"viewInstallations": "Voir les installations",
"noInstallationsInFolder": "Aucune installation dans ce dossier",
"couldNotLoadInstallations": "Impossible de charger les installations",
"retry": "Réessayer",
"UserswithDirectAccess": "Utilisateurs avec accès direct",
"UserswithInheritedAccess": "Utilisateurs avec accès hérité",
"noerrors": "Il n'y a pas d'erreurs",
@ -128,6 +176,7 @@
"unableToLoadData": "Impossible de charger les données",
"unableToRevokeAccess": "Impossible de révoquer l'accès",
"revokedAccessFromUser": "Accès révoqué de l'utilisateur",
"confirmRevokeAccess": "Voulez-vous retirer l'accès à « {name} » ?",
"Show Errors": "Afficher les erreurs",
"Show Warnings": "Afficher les avertissements",
"lastSeen": "Dernière connexion",
@ -483,6 +532,11 @@
"stopTimeMustBeLater": "L'heure d'arrêt doit être postérieure à l'heure de début",
"signIn": "Se connecter",
"username": "Nom d'utilisateur",
"role": "Rôle",
"allRoles": "Tous les rôles",
"roleClient": "Client",
"rolePartner": "Partenaire",
"roleAdmin": "Admin",
"password": "Mot de passe",
"rememberMe": "Se souvenir de moi",
"login": "Connexion",
@ -490,6 +544,13 @@
"forgotPasswordLink": "Mot de passe oublié ?",
"provideYourUsername": "Entrez votre nom d'utilisateur",
"userName": "Nom d'utilisateur",
"userEmailChangedSetPasswordSent": "E-mail mis à jour. Un e-mail de définition du mot de passe a été envoyé à la nouvelle adresse, et l'ancienne adresse a été avertie.",
"userEmailChangeFailedReverted": "Impossible d'envoyer l'e-mail de définition du mot de passe — la modification a été annulée.",
"userEmailChangeNotAllowedOrTaken": "Impossible de modifier l'e-mail — il est déjà utilisé par un autre utilisateur ou n'est pas autorisé.",
"invalidEmailFormat": "Veuillez saisir une adresse e-mail valide.",
"emailChangeConfirmTitle": "Modifier l'e-mail de cet utilisateur ?",
"emailChangeConfirmWarning": "Cela réinitialise le mot de passe de l'utilisateur et avertit à la fois la nouvelle et l'ancienne adresse. L'utilisateur doit définir un nouveau mot de passe via le lien envoyé à la nouvelle adresse.",
"confirm": "Confirmer",
"resetPassword": "Réinitialiser le mot de passe",
"setNewPassword": "Définir un nouveau mot de passe",
"verifyPassword": "Vérifier le mot de passe",
@ -501,6 +562,8 @@
"connectingToDevice": "Connexion à l'appareil en cours...",
"fetchingData": "Récupération des données...",
"confirmDeleteUser": "Voulez-vous supprimer cet utilisateur ?",
"cannotDeleteSelf": "Vous ne pouvez pas supprimer votre propre compte",
"cannotDeleteMainAdmin": "Le compte de l'administrateur principal ne peut pas être supprimé",
"accessManagement": "Gestion des accès",
"power": "Puissance",
"voltage": "Tension",
@ -535,6 +598,16 @@
"stopDateTime": "Date et heure de fin (Début < Fin)",
"installationSetup": "Configuration de l'installation",
"batteryLimits": "Limites de la batterie",
"relayControl": "Commande des relais",
"relay1": "Relais 1",
"relay2": "Relais 2",
"relay3": "Relais 3",
"relay4": "Relais 4",
"digitalInputs": "Entrées numériques",
"digitalInput1": "Entrée numérique 1",
"digitalInput2": "Entrée numérique 2",
"digitalInput3": "Entrée numérique 3",
"digitalInput4": "Entrée numérique 4",
"systemSettings": "Paramètres système",
"pvPerInverter": "PV par onduleur",
"pvInInverter": "PV dans l'onduleur {number}",
@ -544,6 +617,9 @@
"dynamicPricingSpotPrice": "Prix spot",
"dynamicPricingTou": "TOU",
"currentPrice": "Prix actuel",
"currentPriceHistory": "Prix actuel (7 derniers jours)",
"currentPriceNoData": "Aucun historique de prix disponible pour les 7 derniers jours.",
"resetZoom": "Réinitialiser le zoom",
"priceToSell": "Prix de vente",
"priceToBuy": "Prix d'achat",
"timeToSell": "Heure de vente",
@ -739,7 +815,7 @@
"invalidFileType": "Type de fichier non valide.",
"uploadFailed": "Échec du téléchargement.",
"uploadSuccess": "Téléchargement réussi.",
"checklist": "Checklist",
"checklist": "Check-list d'onboarding Monitor",
"checklistTitle": "Étapes pour connecter l'installation à Monitor",
"checklistProgress": "Progression : {done}/{total} ({percent}%)",
"checklistStep": "Étape",
@ -771,10 +847,8 @@
"checklistStep11": "Logiciel vérifié sur site",
"checklistStep12": "Installation en ligne sur Monitor",
"checklistStep13": "Client informé du compte Monitor et des rapports",
"checklistStep14": "Compte utilisateur créé avec les dossiers et accès corrects",
"checklistStep15": "Suivi client effectué, retour recueilli",
"checklistStep16": "Problèmes ultérieurs suivis via le système de tickets",
"checklistStep3Sub1": "Numéro de série de l'installation",
"checklistStep3Sub2": "Compte utilisateur créé avec les dossiers et accès corrects",
"checklistStep5Sub1": "Informations client (e-mail, adresse)",
"checklistStep5Sub2": "Informations d'installation (EMS externe, fournisseur réseau, collecte de données)",
"checklistStep5Sub3": "Numéro de série de la batterie",
@ -794,10 +868,107 @@
"checklistStep10Sub2": "Rapport de temps et matériaux téléversé dans Monitoring",
"checklistStep10Sub3": "Contacter Atef en cas d'EMS externe",
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
"setupProgress": "Progression installation",
"setupProgress": "Progression d'onboarding Monitor",
"batteryVoltageAnomaly": "Plein à basse tension",
"voltageAnomalyLogTitle": "Journal : plein à basse tension",
"voltageAnomalyTime": "Heure",
"noVoltageAnomalies": "Aucune anomalie enregistrée",
"checklistPhaseEmpty": "Non commencé",
"checklistPhasePreparation": "Préparation",
"checklistPhaseOnSite": "Sur site",
"checklistPhaseHandover": "Transfert client",
"checklistPhaseComplete": "Terminé"
"checklistPhaseComplete": "Terminé",
"activePowerPercent": "Puissance active (%)",
"minDischargeVoltageV": "Tension de décharge min. (V)",
"maxDischargeCurrentA": "Courant de décharge max. (A)",
"maxChargeCurrentA": "Courant de charge max. (A)",
"maxChargeVoltageV": "Tension de charge max. (V)",
"onSiteChecklist": "Check-list sur site",
"onSiteChecklistTitle": "Check-list d'installation sur site",
"onSiteChecklistProgress": "Progression : {done}/{total} ({percent}%)",
"onSiteStepHeading": "Étape",
"onSiteImagesHeading": "Photos d'installation",
"onSiteCommentsPlaceholder": "Notes, observations…",
"onSiteSaveFailed": "Impossible d'enregistrer la modification",
"onSiteLocked": "Cette check-list a été signée et est verrouillée.",
"onSiteRemarksTitle": "Remarques de l'installateur",
"onSiteRemarksPlaceholder": "Remarques supplémentaires…",
"onSiteSignatureTitle": "Signature électronique",
"onSiteFullName": "Nom complet",
"onSiteSignButton": "Signer",
"onSiteSignedBy": "Signé par {name} le {date}",
"onSiteSignedSuccess": "Check-list signée avec succès",
"onSiteResetSignature": "Réinitialiser la signature",
"onSiteShowMore": "Afficher plus",
"onSiteShowLess": "Afficher moins",
"onSiteConsentShort": "Je confirme que l'installation a été réalisée et mise en service conformément à la check-list ci-dessus.",
"onSiteConsentLong": "Par la signature électronique, l'installateur exécutant confirme que l'installation a été réalisée et mise en service conformément à la check-list ci-dessus et aux règles techniques reconnues. Les informations ci-dessus sont complètes et véridiques. Cette confirmation fait partie de la mise en service et est transmise avec la check-list à inesco energy.",
"onSiteStep1Title": "Déballage",
"onSiteStep1Sub1": "Déballer sodistore home avec précaution",
"onSiteStep1Sub2": "Vérifier les conditions du site (environnement sec 20 à +55°C, surface plane)",
"onSiteStep1Warning": "En cas de dommages de transport, veuillez les documenter avec des photos et les signaler à inesco.",
"onSiteStep2Title": "Monter l'onduleur inesco",
"onSiteStep2Sub1": "Remonter l'onduleur sur son support",
"onSiteStep2Sub2": "Vérifier que les câbles n'ont pas été endommagés pendant le transport",
"onSiteStep2Sub3": "Reconnecter le câble AC : GRID vers GRID et BACK-UP vers Load",
"onSiteStep3Title": "Travaux dans le tableau principal (côté AC)",
"onSiteStep3Sub1": "Installer la partie AC selon le schéma",
"onSiteStep3Sub2": "Installer l'inverseur de réseau inesco (recommandé)",
"onSiteStep3Sub3": "Installer le disjoncteur de l'onduleur (25A ou 40A selon le modèle)",
"onSiteStep3Sub4": "Installer l'interrupteur d'installation pour sodistore home",
"onSiteStep3Sub5": "Raccorder le câble d'arrivée (Grid) à sodistore home",
"onSiteStep3Sub6": "Raccorder le câble Load à sodistore home",
"onSiteStep3Subheading1": "Exigences pour l'alimentation de secours",
"onSiteStep3Sub7": "Définir les groupes alimentés en secours dans le tableau principal",
"onSiteStep3Sub8": "Attention : les groupes TNS et TNC ne doivent pas être mélangés !!!",
"onSiteStep3Sub9": "L'onduleur doit être réglé sur TNS ou TNC selon le système",
"onSiteStep3Sub10": "Protéger les groupes de secours avec un disjoncteur Type B et un DDR 300mA",
"onSiteStep3Warning": "inesco energy recommande un inverseur de réseau en mode secours. En cas de panne, l'installation peut être basculée sur le réseau et la maison reste alimentée en énergie.",
"onSiteStep4Title": "Raccordement du compteur",
"onSiteStep4Sub1": "Installer le compteur dans le tableau principal ou dans sodistore home",
"onSiteStep4Sub2": "Installer disjoncteur / 3LN pour la tension de référence du compteur",
"onSiteStep4Sub3": "Pincer les TC autour du câble d'arrivée — respecter le bon sens",
"onSiteStep4Sub4": "Installer et brancher le câble bus du compteur à l'onduleur (rallonge autorisée)",
"onSiteStep4Warning": "Les câbles des TC ne doivent pas être rallongés ni raccourcis, sinon les mesures seront fausses. Les TC doivent garder une distance minimale de 15 cm avec d'autres TC ou des signaux haute fréquence dans la zone haute tension, sinon les mesures peuvent également être erronées.",
"onSiteStep5Title": "inesco Gateway et internet",
"onSiteStep5Sub1": "Tirer un câble internet jusqu'au inesco Gateway",
"onSiteStep5Sub2": "Vérifier la connexion inesco Gateway — onduleur (USB vers BMS)",
"onSiteStep5Sub3": "Alimenter inesco Gateway",
"onSiteStep5Sub4": "Établir la connexion WiFi avec l'onduleur",
"onSiteStep5Warning": "Les onduleurs nécessitent impérativement une connexion WiFi — pas de LAN RJ45 — sinon ils ne peuvent pas communiquer.",
"onSiteStep6Title": "Connexions système",
"onSiteStep6Sub1": "Connexions de terre",
"onSiteStep6Sub2": "inesco Gateway (USB) connecté à l'onduleur (via splitter)",
"onSiteStep6Sub3": "Batteries +/- vers l'onduleur selon le schéma",
"onSiteStep6Sub4": "CAN de la batterie vers l'onduleur sur BMS (via splitter)",
"onSiteStep6Sub5": "Chaînage des batteries RS485 2 vers RS485 2",
"onSiteStep6Sub6": "Du splitter vers l'onduleur",
"onSiteStep6Sub7": "Pour les ensembles 27/36, les onduleurs doivent être reliés via PARA",
"onSiteStep6Warning1": "Les connexions système doivent être câblées selon le schéma. Chaque ensemble (9, 18, 27 et 36) a son propre schéma à respecter.",
"onSiteStep6Warning2": "Les câbles RJ45 ne doivent pas être inversés. Le câble noir est connecté au contact A et va vers le inesco Gateway en USB. Le contact B est connecté à la batterie.",
"onSiteStep6Warning3": "Les câbles ont des brochages différents et sont sertis en conséquence — ils ne doivent pas être inversés.",
"onSiteStep6Warning4": "Chaîner au maximum 2 batteries +/-.",
"onSiteStep7Title": "Raccordement de l'onduleur PV",
"onSiteStep7Sub1": "Raccorder les lignes DC à l'onduleur inesco — respecter les tensions des strings.",
"onSiteStep7Warning": "Si l'installation est couplée en AC, l'onduleur existant reste en place et l'onduleur inesco n'est pas raccordé aux lignes DC de l'installation PV.",
"onSiteStep8Title": "Mise en service",
"onSiteStep8Sub1": "Mise en service par téléphone avec inesco",
"onSiteStep8Sub2": "Inspection visuelle",
"onSiteStep8Sub3": "Mesures selon la norme — DC et AC",
"onSiteStep8Sub4": "Mettre sous tension",
"onSiteStep8Sub5": "Enclencher l'interrupteur DC sur l'onduleur",
"onSiteStep8Sub6": "Allumer les batteries — placer le bouton sur ON",
"onSiteStep8Sub7": "Placer le disjoncteur des batteries sur ON",
"onSiteStep8Sub8": "Vérifier que tous les appareils sont affichés (onduleur, batterie, inesco Gateway)",
"onSiteStep8Sub9": "Ouvrir l'installation via l'application ESS Link",
"onSiteStep8Sub10": "Configurer pour le client via l'application ESS Link",
"onSiteStep8Sub11": "Comparer le compteur de l'utilité (mêmes valeurs kW que sur le portail)"
}

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Rapporto allarmi",
"exportCsv": "Esporta CSV",
"refresh": "Aggiorna",
"alarmReportRetentionNote": "Orari mostrati in ora svizzera (CET/CEST). Vengono mostrati gli allarmi attualmente conservati — fino agli ultimi 100 eventi per installazione.",
"filterSeverity": "Gravità",
"severityBoth": "Errori e avvisi",
"severityErrors": "Solo errori",
"severityWarnings": "Solo avvisi",
"filterProduct": "Prodotto",
"filterDevice": "Dispositivo",
"filterInstallation": "Installazione",
"filterAllInstallations": "Tutte le installazioni",
"filterTimeRange": "Intervallo di tempo",
"rangeAll": "Tutti i conservati",
"rangeToday": "Oggi",
"rangeLast7": "Ultimi 7 giorni",
"rangeThisMonth": "Questo mese",
"rangeLastMonth": "Mese scorso",
"rangeCustom": "Personalizzato…",
"rangeFrom": "Da",
"rangeTo": "A",
"filterSearch": "Cerca",
"filterView": "Vista",
"viewGrouped": "Raggruppato",
"viewRaw": "Eventi singoli",
"alarmReportLoadError": "Impossibile caricare il rapporto allarmi.",
"alarmReportGroupCount": "{groups} allarmi distinti · {events} eventi",
"alarmReportEventCount": "{events} eventi",
"colInstallation": "Installazione",
"colDevice": "Dispositivo",
"colAlarm": "Allarme",
"colSeverity": "Tipo",
"colCount": "Conteggio",
"colFirstSeen": "Primo rilevamento (CH)",
"colLastSeen": "Ultimo rilevamento (CH)",
"colDate": "Data",
"colTime": "Ora (CH)",
"alarmReportNoResults": "Nessun allarme corrisponde ai filtri selezionati.",
"allInstallations": "Tutte le installazioni",
"applyChanges": "Applica modifiche",
"country": "Paese",
@ -78,6 +116,12 @@
"couplingType": "Accoppiamento AC/DC",
"couplingAC": "Accoppiamento AC",
"couplingDC": "Accoppiamento DC",
"couplingMixed": "Misto",
"backupLoad": "Capacità di backup",
"backupWhole": "Intera casa",
"backupPartial": "Parziale",
"backupNone": "Non utilizzato",
"notSet": "Non impostato",
"selectModel": "Seleziona modello...",
"inverterN": "Inverter {n}",
"clusterN": "Cluster {n}",
@ -96,6 +140,10 @@
"errorOccured": "Si è verificato un errore",
"successfullyUpdated": "Aggiornamento riuscito",
"grantAccess": "Concedi accesso",
"viewInstallations": "Mostra installazioni",
"noInstallationsInFolder": "Nessuna installazione in questa cartella",
"couldNotLoadInstallations": "Impossibile caricare le installazioni",
"retry": "Riprova",
"UserswithDirectAccess": "Utenti con accesso diretto",
"UserswithInheritedAccess": "Utenti con accesso ereditato",
"noerrors": "Non ci sono errori",
@ -116,6 +164,7 @@
"unableToLoadData": "Impossibile caricare i dati",
"unableToRevokeAccess": "Impossibile revocare l'accesso",
"revokedAccessFromUser": "Accesso revocato all'utente: ",
"confirmRevokeAccess": "Vuoi rimuovere l'accesso a \"{name}\"?",
"alarms": "Allarmi",
"overview": "Panoramica",
"manage": "Gestione accessi",
@ -483,6 +532,11 @@
"stopTimeMustBeLater": "L'ora di fine deve essere successiva all'ora di inizio",
"signIn": "Accedi",
"username": "Nome utente",
"role": "Ruolo",
"allRoles": "Tutti i ruoli",
"roleClient": "Cliente",
"rolePartner": "Partner",
"roleAdmin": "Admin",
"password": "Password",
"rememberMe": "Ricordami",
"login": "Accedi",
@ -490,6 +544,13 @@
"forgotPasswordLink": "Password dimenticata?",
"provideYourUsername": "Inserisci il tuo nome utente",
"userName": "Nome utente",
"userEmailChangedSetPasswordSent": "Email aggiornata. Un'email per impostare la password è stata inviata al nuovo indirizzo e il precedente è stato avvisato.",
"userEmailChangeFailedReverted": "Impossibile inviare l'email per impostare la password — la modifica è stata annullata.",
"userEmailChangeNotAllowedOrTaken": "Impossibile modificare l'email — è già utilizzata da un altro utente o non è consentita.",
"invalidEmailFormat": "Inserisci un indirizzo email valido.",
"emailChangeConfirmTitle": "Modificare l'email di questo utente?",
"emailChangeConfirmWarning": "Questo reimposta la password dell'utente e avvisa sia il nuovo indirizzo sia quello precedente. L'utente deve impostare una nuova password tramite il link inviato al nuovo indirizzo.",
"confirm": "Conferma",
"resetPassword": "Reimposta password",
"setNewPassword": "Imposta nuova password",
"verifyPassword": "Verifica password",
@ -501,6 +562,8 @@
"connectingToDevice": "Connessione al dispositivo in corso...",
"fetchingData": "Recupero dati in corso...",
"confirmDeleteUser": "Vuoi eliminare questo utente?",
"cannotDeleteSelf": "Non puoi eliminare il tuo account",
"cannotDeleteMainAdmin": "L'account dell'amministratore principale non può essere eliminato",
"accessManagement": "Gestione accessi",
"power": "Potenza",
"voltage": "Tensione",
@ -535,6 +598,16 @@
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
"installationSetup": "Configurazione dell'installazione",
"batteryLimits": "Limiti della batteria",
"relayControl": "Controllo relè",
"relay1": "Relè 1",
"relay2": "Relè 2",
"relay3": "Relè 3",
"relay4": "Relè 4",
"digitalInputs": "Ingressi digitali",
"digitalInput1": "Ingresso digitale 1",
"digitalInput2": "Ingresso digitale 2",
"digitalInput3": "Ingresso digitale 3",
"digitalInput4": "Ingresso digitale 4",
"systemSettings": "Impostazioni di sistema",
"pvPerInverter": "PV per inverter",
"pvInInverter": "PV nell'inverter {number}",
@ -544,6 +617,9 @@
"dynamicPricingSpotPrice": "Prezzo spot",
"dynamicPricingTou": "TOU",
"currentPrice": "Prezzo attuale",
"currentPriceHistory": "Prezzo attuale (ultimi 7 giorni)",
"currentPriceNoData": "Nessuno storico prezzi disponibile per gli ultimi 7 giorni.",
"resetZoom": "Reimposta zoom",
"priceToSell": "Prezzo di vendita",
"priceToBuy": "Prezzo di acquisto",
"timeToSell": "Orario di vendita",
@ -739,7 +815,7 @@
"invalidFileType": "Tipo di file non valido.",
"uploadFailed": "Caricamento fallito.",
"uploadSuccess": "Caricamento riuscito.",
"checklist": "Checklist",
"checklist": "Checklist di onboarding Monitor",
"checklistTitle": "Passi per collegare l'installazione a Monitor",
"checklistProgress": "Avanzamento: {done}/{total} ({percent}%)",
"checklistStep": "Passo",
@ -771,10 +847,8 @@
"checklistStep11": "Software verificato in sito",
"checklistStep12": "Installazione online su Monitor",
"checklistStep13": "Cliente informato su account Monitor e report",
"checklistStep14": "Account utente creato con cartelle e accessi corretti",
"checklistStep15": "Follow-up cliente completato, feedback raccolto",
"checklistStep16": "Ulteriori problemi tracciati tramite il sistema di ticket",
"checklistStep3Sub1": "Numero di serie dell'installazione",
"checklistStep3Sub2": "Account utente creato con cartelle e accessi corretti",
"checklistStep5Sub1": "Informazioni cliente (e-mail, indirizzo)",
"checklistStep5Sub2": "Informazioni installazione (EMS esterno, fornitore di rete, raccolta dati)",
"checklistStep5Sub3": "Numero di serie batteria",
@ -794,10 +868,107 @@
"checklistStep10Sub2": "Rapporto tempi e materiali caricato su Monitoring",
"checklistStep10Sub3": "Contattare Atef se è presente un EMS esterno",
"checklistNoAttachments": "Nessun file allegato.",
"setupProgress": "Avanzamento installazione",
"setupProgress": "Avanzamento onboarding Monitor",
"batteryVoltageAnomaly": "Pieno a bassa tensione",
"voltageAnomalyLogTitle": "Registro: pieno a bassa tensione",
"voltageAnomalyTime": "Ora",
"noVoltageAnomalies": "Nessuna anomalia registrata",
"checklistPhaseEmpty": "Non avviato",
"checklistPhasePreparation": "Preparazione",
"checklistPhaseOnSite": "In sito",
"checklistPhaseHandover": "Consegna cliente",
"checklistPhaseComplete": "Completato"
"checklistPhaseComplete": "Completato",
"activePowerPercent": "Potenza attiva (%)",
"minDischargeVoltageV": "Tensione di scarica min. (V)",
"maxDischargeCurrentA": "Corrente di scarica max. (A)",
"maxChargeCurrentA": "Corrente di carica max. (A)",
"maxChargeVoltageV": "Tensione di carica max. (V)",
"onSiteChecklist": "Checklist in sito",
"onSiteChecklistTitle": "Checklist di installazione in sito",
"onSiteChecklistProgress": "Avanzamento: {done}/{total} ({percent}%)",
"onSiteStepHeading": "Passaggio",
"onSiteImagesHeading": "Foto dell'installazione",
"onSiteCommentsPlaceholder": "Note, osservazioni…",
"onSiteSaveFailed": "Impossibile salvare la modifica",
"onSiteLocked": "Questa checklist è stata firmata ed è bloccata.",
"onSiteRemarksTitle": "Osservazioni dell'installatore",
"onSiteRemarksPlaceholder": "Osservazioni aggiuntive…",
"onSiteSignatureTitle": "Firma elettronica",
"onSiteFullName": "Nome completo",
"onSiteSignButton": "Firma",
"onSiteSignedBy": "Firmato da {name} il {date}",
"onSiteSignedSuccess": "Checklist firmata con successo",
"onSiteResetSignature": "Reimposta firma",
"onSiteShowMore": "Mostra di più",
"onSiteShowLess": "Mostra di meno",
"onSiteConsentShort": "Confermo che l'installazione è stata eseguita e messa in servizio secondo la checklist sopra.",
"onSiteConsentLong": "Con la firma elettronica, l'installatore esecutore conferma che l'installazione è stata eseguita e messa in servizio secondo la checklist sopra e le regole tecniche riconosciute. Le informazioni sopra sono complete e veritiere. Questa conferma fa parte della messa in servizio e viene trasmessa insieme alla checklist a inesco energy.",
"onSiteStep1Title": "Disimballaggio",
"onSiteStep1Sub1": "Disimballare sodistore home con cura",
"onSiteStep1Sub2": "Verificare le condizioni del sito (ambiente asciutto 20 a +55°C, superficie piana)",
"onSiteStep1Warning": "In caso di danni da trasporto, documentarli con foto e segnalarli a inesco.",
"onSiteStep2Title": "Installare l'inverter inesco",
"onSiteStep2Sub1": "Rimontare l'inverter sul supporto",
"onSiteStep2Sub2": "Verificare che i cavi non siano stati danneggiati durante il trasporto",
"onSiteStep2Sub3": "Ricollegare il cavo AC: GRID a GRID e BACK-UP a Load",
"onSiteStep3Title": "Lavori nel quadro principale (lato AC)",
"onSiteStep3Sub1": "Installare l'AC secondo lo schema",
"onSiteStep3Sub2": "Installare il commutatore di rete inesco (consigliato)",
"onSiteStep3Sub3": "Installare l'interruttore dell'inverter (25A o 40A a seconda del modello)",
"onSiteStep3Sub4": "Installare l'interruttore di impianto per sodistore home",
"onSiteStep3Sub5": "Collegare il cavo di alimentazione (Grid) a sodistore home",
"onSiteStep3Sub6": "Collegare il cavo Load a sodistore home",
"onSiteStep3Subheading1": "Requisiti per l'alimentazione di emergenza",
"onSiteStep3Sub7": "Definire i gruppi alimentati in emergenza nel quadro principale",
"onSiteStep3Sub8": "Attenzione: i gruppi TNS e TNC non devono essere mischiati !!!",
"onSiteStep3Sub9": "L'inverter deve essere impostato su TNS o TNC a seconda del sistema",
"onSiteStep3Sub10": "Proteggere i gruppi di emergenza con interruttore Tipo B e differenziale 300mA",
"onSiteStep3Warning": "inesco energy raccomanda un commutatore di rete in modalità di emergenza. In caso di guasto l'impianto può essere commutato sulla rete e la casa rimane comunque alimentata.",
"onSiteStep4Title": "Collegamento del contatore",
"onSiteStep4Sub1": "Installare il contatore nel quadro principale o all'interno di sodistore home",
"onSiteStep4Sub2": "Installare interruttore / 3LN per la tensione di riferimento del contatore",
"onSiteStep4Sub3": "Pinzare i TA attorno al cavo di alimentazione — rispettare il verso corretto",
"onSiteStep4Sub4": "Installare e collegare il cavo bus dal contatore all'inverter (prolunga consentita)",
"onSiteStep4Warning": "I cavi dei TA non devono essere prolungati né accorciati, altrimenti le misure risulteranno sbagliate. I TA devono mantenere una distanza minima di 15 cm da altri TA o segnali ad alta frequenza nell'area di alta tensione, altrimenti anche le misure possono risultare errate.",
"onSiteStep5Title": "inesco Gateway e internet",
"onSiteStep5Sub1": "Tirare un cavo internet fino al inesco Gateway",
"onSiteStep5Sub2": "Verificare il collegamento inesco Gateway — inverter (USB verso BMS)",
"onSiteStep5Sub3": "Alimentare inesco Gateway",
"onSiteStep5Sub4": "Stabilire la connessione WiFi con l'inverter",
"onSiteStep5Warning": "Gli inverter richiedono assolutamente una connessione WiFi — non LAN RJ45 — altrimenti non possono comunicare.",
"onSiteStep6Title": "Connessioni di sistema",
"onSiteStep6Sub1": "Collegamenti di terra",
"onSiteStep6Sub2": "inesco Gateway (USB) collegato all'inverter (tramite splitter)",
"onSiteStep6Sub3": "Batterie +/- verso l'inverter secondo lo schema",
"onSiteStep6Sub4": "CAN dalla batteria all'inverter su BMS (tramite splitter)",
"onSiteStep6Sub5": "Concatenare le batterie RS485 2 a RS485 2",
"onSiteStep6Sub6": "Dallo splitter all'inverter",
"onSiteStep6Sub7": "Per gli assemblaggi 27/36 gli inverter devono essere collegati via PARA",
"onSiteStep6Warning1": "Le connessioni di sistema devono essere cablate secondo lo schema. Ogni assemblaggio (9, 18, 27 e 36) ha il proprio schema da rispettare.",
"onSiteStep6Warning2": "I cavi RJ45 non devono essere invertiti. Il cavo nero è collegato al contatto A e va al inesco Gateway tramite USB. Il contatto B è collegato alla batteria.",
"onSiteStep6Warning3": "I cavi hanno piedinature diverse e sono crimpati di conseguenza — non devono essere invertiti.",
"onSiteStep6Warning4": "Concatenare al massimo 2 batterie +/-.",
"onSiteStep7Title": "Collegamento inverter PV",
"onSiteStep7Sub1": "Collegare le linee DC all'inverter inesco — rispettare le tensioni di stringa.",
"onSiteStep7Warning": "Se l'impianto è accoppiato in AC, l'inverter esistente rimane in funzione e l'inverter inesco non viene collegato alle linee DC dell'impianto PV.",
"onSiteStep8Title": "Messa in servizio",
"onSiteStep8Sub1": "Messa in servizio per telefono con inesco",
"onSiteStep8Sub2": "Ispezione visiva",
"onSiteStep8Sub3": "Misurazioni secondo la norma — DC e AC",
"onSiteStep8Sub4": "Mettere sotto tensione",
"onSiteStep8Sub5": "Accendere l'interruttore DC sull'inverter",
"onSiteStep8Sub6": "Accendere le batterie — portare il pulsante su ON",
"onSiteStep8Sub7": "Portare il breaker delle batterie su ON",
"onSiteStep8Sub8": "Verificare che tutti i dispositivi siano visualizzati (inverter, batteria, inesco Gateway)",
"onSiteStep8Sub9": "Aprire l'impianto tramite l'app ESS Link",
"onSiteStep8Sub10": "Configurare per il cliente tramite l'app ESS Link",
"onSiteStep8Sub11": "Confrontare il contatore del distributore (stessi valori in kW del portale)"
}

View File

@ -14,6 +14,7 @@ import { SidebarContext } from 'src/contexts/SidebarContext';
import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone';
import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone';
import ConfirmationNumberTwoToneIcon from '@mui/icons-material/ConfirmationNumberTwoTone';
import AssessmentTwoToneIcon from '@mui/icons-material/AssessmentTwoTone';
import { FormattedMessage } from 'react-intl';
import { UserContext } from '../../../../contexts/userContext';
import { UserType } from '../../../../interfaces/UserTypes';
@ -347,6 +348,19 @@ function SidebarMenu() {
</Button>
</ListItem>
</List>
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/alarm_report"
startIcon={<AssessmentTwoToneIcon />}
>
<FormattedMessage id="alarmReportTitle" defaultMessage="Alarm Report" />
</Button>
</ListItem>
</List>
</SubMenuWrapper>
</List>
)}