Compare commits

...

7 Commits

25 changed files with 702 additions and 69 deletions

View File

@ -498,6 +498,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)
@ -3047,6 +3061,45 @@ 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))]

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

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

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

View File

@ -50,6 +50,9 @@ public static partial class Db
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()
{
@ -98,6 +101,9 @@ public static partial class Db
// 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
@ -168,6 +174,14 @@ 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");
@ -200,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")
@ -248,6 +287,9 @@ public static partial class Db
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");

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

@ -272,4 +272,19 @@ public static partial class Db
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

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

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

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

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

@ -550,7 +550,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
total={summary?.total ?? 13}
/>
);
})()}

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).
@ -158,6 +184,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
/>
</TableCell>
)}
{showAnomalyColumn && (
<TableCell>
<FormattedMessage
id="batteryVoltageAnomaly"
defaultMessage="Full @ low voltage"
/>
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
@ -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

@ -48,12 +48,20 @@ 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>
@ -114,14 +122,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,6 +25,7 @@ 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 UserAccess from '../ManageAccess/UserAccess';
@ -43,6 +45,8 @@ function User(props: singleUserProps) {
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' }) }
@ -161,6 +165,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 && (
@ -350,18 +365,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

View File

@ -20,8 +20,8 @@ import { UserType } from '../../../interfaces/UserTypes';
function UsersSearch() {
const intl = useIntl();
const [searchTerm, setSearchTerm] = useState('');
const { availableUsers, fetchAvailableUsers } = useContext(AccessContext);
const [filteredData, setFilteredData] = useState(availableUsers);
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 +29,19 @@ function UsersSearch() {
const { currentUser } = context;
useEffect(() => {
fetchAvailableUsers();
fetchAllUsers();
}, []);
const fetchDataAgain = () => {
fetchAvailableUsers();
fetchAllUsers();
};
useEffect(() => {
const filtered = availableUsers.filter((item) =>
const filtered = allUsers.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
setFilteredData(filtered);
}, [searchTerm, availableUsers]);
}, [searchTerm, allUsers]);
const handleSubmit = () => {
setOpenModal(true);
@ -50,7 +50,7 @@ function UsersSearch() {
setOpenModal(false);
setUserCreated(true);
fetchAvailableUsers();
fetchAllUsers();
setTimeout(() => {
setUserCreated(false);

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

@ -502,6 +502,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",
@ -777,10 +779,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",
@ -801,6 +801,10 @@
"checklistStep10Sub3": "Atef kontaktieren, falls externes EMS vorhanden ist",
"checklistNoAttachments": "Noch keine Datei angehängt.",
"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",

View File

@ -250,6 +250,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",
@ -525,10 +527,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",
@ -549,6 +549,10 @@
"checklistStep10Sub3": "Contact Atef if there is external EMS",
"checklistNoAttachments": "No file attached yet.",
"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",

View File

@ -502,6 +502,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",
@ -777,10 +779,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",
@ -801,6 +801,10 @@
"checklistStep10Sub3": "Contacter Atef en cas d'EMS externe",
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
"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",

View File

@ -502,6 +502,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",
@ -777,10 +779,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",
@ -801,6 +801,10 @@
"checklistStep10Sub3": "Contattare Atef se è presente un EMS esterno",
"checklistNoAttachments": "Nessun file allegato.",
"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",