Merge branch 'main' into sinexcel_multiinveters_configurtaion
This commit is contained in:
commit
e706caf390
|
|
@ -9,20 +9,21 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- uses: actions/setup-dotnet@v3
|
||||
- uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
|
||||
with:
|
||||
dotnet-version: '7.0.x'
|
||||
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
|
||||
- run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true
|
||||
- run: |
|
||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install
|
||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts
|
||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
|
||||
|
||||
|
||||
- name: stop services
|
||||
uses: appleboy/ssh-action@v0.1.4
|
||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
||||
with:
|
||||
host: 194.182.190.208
|
||||
username: ubuntu
|
||||
|
|
@ -31,7 +32,7 @@ jobs:
|
|||
sudo systemctl stop backend
|
||||
|
||||
- name: Copy Backend
|
||||
uses: appleboy/scp-action@v0.1.4
|
||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
||||
with:
|
||||
host: 194.182.190.208
|
||||
username: ubuntu
|
||||
|
|
@ -42,7 +43,7 @@ jobs:
|
|||
strip_components: 1
|
||||
|
||||
- name: Copy Frontend
|
||||
uses: appleboy/scp-action@v0.1.4
|
||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
||||
with:
|
||||
host: 194.182.190.208
|
||||
username: ubuntu
|
||||
|
|
@ -53,12 +54,11 @@ jobs:
|
|||
strip_components: 1
|
||||
|
||||
- name: restart services
|
||||
uses: appleboy/ssh-action@v0.1.4
|
||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
||||
with:
|
||||
host: 194.182.190.208
|
||||
username: ubuntu
|
||||
password: ${{ secrets.PRODUCTION_SSH_PASSPHRASE }}
|
||||
script: |
|
||||
sudo systemctl restart backend
|
||||
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/
|
||||
sudo npm install -g serve
|
||||
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/
|
||||
|
|
@ -9,19 +9,20 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- uses: actions/setup-dotnet@v3
|
||||
- uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
|
||||
with:
|
||||
dotnet-version: '7.0.x'
|
||||
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
|
||||
- run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true
|
||||
- run: |
|
||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install
|
||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts
|
||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
|
||||
|
||||
- name: stop services
|
||||
uses: appleboy/ssh-action@v0.1.4
|
||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
||||
with:
|
||||
host: 91.92.154.141
|
||||
username: ubuntu
|
||||
|
|
@ -30,7 +31,7 @@ jobs:
|
|||
sudo systemctl stop backend
|
||||
|
||||
- name: Copy Backend
|
||||
uses: appleboy/scp-action@v0.1.4
|
||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
||||
with:
|
||||
host: 91.92.154.141
|
||||
username: ubuntu
|
||||
|
|
@ -41,7 +42,7 @@ jobs:
|
|||
strip_components: 11
|
||||
|
||||
- name: Copy Frontend
|
||||
uses: appleboy/scp-action@v0.1.4
|
||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
||||
with:
|
||||
host: 91.92.154.141
|
||||
username: ubuntu
|
||||
|
|
@ -52,12 +53,11 @@ jobs:
|
|||
strip_components: 5
|
||||
|
||||
- name: restart services
|
||||
uses: appleboy/ssh-action@v0.1.4
|
||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
||||
with:
|
||||
host: 91.92.154.141
|
||||
username: ubuntu
|
||||
password: ${{ secrets.STAGE_SSH_PASSPHRASE }}
|
||||
script: |
|
||||
sudo systemctl restart backend
|
||||
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/
|
||||
sudo npm install -g serve
|
||||
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/
|
||||
|
|
@ -7,6 +7,8 @@ using InnovEnergy.App.Backend.DataTypes.Methods;
|
|||
using InnovEnergy.App.Backend.Relations;
|
||||
using InnovEnergy.App.Backend.Services;
|
||||
using InnovEnergy.App.Backend.Websockets;
|
||||
using InnovEnergy.Lib.S3Utils;
|
||||
using InnovEnergy.Lib.S3Utils.DataTypes;
|
||||
using InnovEnergy.Lib.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
|
|
@ -202,6 +204,8 @@ public class Controller : ControllerBase
|
|||
bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp;
|
||||
else if (installation.Product == (int)ProductType.SodistoreGrid)
|
||||
bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp;
|
||||
else if (installation.Product == (int)ProductType.SodistorePro)
|
||||
bucketPath = "s3://" + installation.S3BucketId + "-325c9373-9025-4a8d-bf5a-f9eedf1f155c/" + startTimestamp;
|
||||
else
|
||||
bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp;
|
||||
Console.WriteLine("Fetching data for "+startTimestamp);
|
||||
|
|
@ -815,9 +819,10 @@ public class Controller : ControllerBase
|
|||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
// AI diagnostics are scoped to SodistoreHome and SodiStoreMax only
|
||||
// AI diagnostics are scoped to SodistoreHome, SodiStoreMax, and SodistorePro only
|
||||
if (installation.Product != (int)ProductType.SodioHome &&
|
||||
installation.Product != (int)ProductType.SodiStoreMax)
|
||||
installation.Product != (int)ProductType.SodiStoreMax &&
|
||||
installation.Product != (int)ProductType.SodistorePro)
|
||||
return BadRequest("AI diagnostics not available for this product.");
|
||||
|
||||
var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en");
|
||||
|
|
@ -919,8 +924,85 @@ public class Controller : ControllerBase
|
|||
});
|
||||
}
|
||||
|
||||
// ── Email Preferences ──────────────────────────────────────────────
|
||||
|
||||
[HttpGet(nameof(GetEmailPreference))]
|
||||
public ActionResult GetEmailPreference(Int64 installationId, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var pref = Db.GetEmailPreference(installationId);
|
||||
return Ok(new
|
||||
{
|
||||
installationId,
|
||||
sendWeekly = pref?.SendWeekly ?? false,
|
||||
sendMonthly = pref?.SendMonthly ?? false,
|
||||
sendYearly = pref?.SendYearly ?? false
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost(nameof(UpdateEmailPreference))]
|
||||
public ActionResult UpdateEmailPreference(
|
||||
Int64 installationId, Boolean sendWeekly, Boolean sendMonthly,
|
||||
Boolean sendYearly, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
Db.UpsertEmailPreference(new EmailPreference
|
||||
{
|
||||
InstallationId = installationId,
|
||||
SendWeekly = sendWeekly,
|
||||
SendMonthly = sendMonthly,
|
||||
SendYearly = sendYearly
|
||||
});
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Weekly Performance Report ──────────────────────────────────────
|
||||
|
||||
private async Task<WeeklyReportResponse?> FetchWeeklyReportAsync(
|
||||
Int64 installationId, String installationName, String lang,
|
||||
DateOnly? weekStartDate = null, Boolean forceRegenerate = false)
|
||||
{
|
||||
DateOnly periodStart, periodEnd;
|
||||
if (weekStartDate.HasValue)
|
||||
{
|
||||
periodStart = weekStartDate.Value;
|
||||
periodEnd = weekStartDate.Value.AddDays(6);
|
||||
}
|
||||
else
|
||||
{
|
||||
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
|
||||
}
|
||||
|
||||
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
|
||||
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
|
||||
|
||||
if (!forceRegenerate)
|
||||
{
|
||||
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
|
||||
if (cached != null)
|
||||
{
|
||||
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
|
||||
if (cachedResponse != null)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}");
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}");
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installationId, installationName, lang, weekStartDate);
|
||||
|
||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||
return report;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a weekly performance report. Serves from cache if available;
|
||||
/// generates fresh on first request or when forceRegenerate is true.
|
||||
|
|
@ -951,43 +1033,9 @@ public class Controller : ControllerBase
|
|||
{
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
|
||||
// Compute target week dates for cache lookup
|
||||
DateOnly periodStart, periodEnd;
|
||||
if (weekStartDate.HasValue)
|
||||
{
|
||||
periodStart = weekStartDate.Value;
|
||||
periodEnd = weekStartDate.Value.AddDays(6);
|
||||
}
|
||||
else
|
||||
{
|
||||
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
|
||||
}
|
||||
|
||||
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
|
||||
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
|
||||
|
||||
// Cache-first: check if a cached report exists for this week
|
||||
if (!forceRegenerate)
|
||||
{
|
||||
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
|
||||
if (cached != null)
|
||||
{
|
||||
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
|
||||
if (cachedResponse != null)
|
||||
{
|
||||
Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}");
|
||||
return Ok(cachedResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or forceRegenerate: generate fresh
|
||||
Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}");
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installationId, installation.Name, lang, weekStartDate);
|
||||
|
||||
// Persist weekly summary and seed AiInsightCache for this language
|
||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate, forceRegenerate);
|
||||
if (report == null)
|
||||
return BadRequest("Failed to generate report.");
|
||||
|
||||
return Ok(report);
|
||||
}
|
||||
|
|
@ -1094,6 +1142,46 @@ public class Controller : ControllerBase
|
|||
return Ok(reports);
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetCurrentMonthPreview))]
|
||||
public async Task<ActionResult<MonthlyReportSummary>> GetCurrentMonthPreview(
|
||||
Int64 installationId, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var preview = await ReportAggregationService.GetCurrentMonthPreviewAsync(installationId, lang);
|
||||
if (preview == null)
|
||||
return NotFound("No daily data for the current month.");
|
||||
|
||||
return Ok(preview);
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetCurrentYearPreview))]
|
||||
public async Task<ActionResult<YearlyReportSummary>> GetCurrentYearPreview(
|
||||
Int64 installationId, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var preview = await ReportAggregationService.GetCurrentYearPreviewAsync(installationId, lang);
|
||||
if (preview == null)
|
||||
return NotFound("No monthly reports for the current year.");
|
||||
|
||||
return Ok(preview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger monthly aggregation for an installation.
|
||||
/// Computes monthly report from daily records for the specified year/month.
|
||||
|
|
@ -1474,7 +1562,9 @@ public class Controller : ControllerBase
|
|||
// ── Report HTML (for PDF download) ─────────────────────────────
|
||||
|
||||
[HttpGet(nameof(GetWeeklyReportHtml))]
|
||||
public async Task<ActionResult> GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null)
|
||||
public async Task<ActionResult> GetWeeklyReportHtml(
|
||||
Int64 installationId, Token authToken,
|
||||
String? language = null, String? weekStart = null, String source = "email")
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
|
@ -1482,14 +1572,26 @@ public class Controller : ControllerBase
|
|||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
|
||||
var html = ReportEmailService.BuildHtmlEmail(report, lang);
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
|
||||
DateOnly? weekStartDate = null;
|
||||
if (!String.IsNullOrEmpty(weekStart))
|
||||
{
|
||||
if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed))
|
||||
return BadRequest("weekStart must be in yyyy-MM-dd format.");
|
||||
weekStartDate = parsed;
|
||||
}
|
||||
|
||||
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate);
|
||||
if (report == null)
|
||||
return BadRequest("Failed to generate report.");
|
||||
|
||||
var html = ReportEmailService.BuildHtmlEmail(report, lang, source: source);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetMonthlyReportHtml))]
|
||||
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
|
||||
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null, String source = "email")
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
|
@ -1508,12 +1610,12 @@ public class Controller : ControllerBase
|
|||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||
$"{report.WeekCount} {s.CountLabel}", s);
|
||||
$"{report.WeekCount} {s.CountLabel}", s, source: source);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetYearlyReportHtml))]
|
||||
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null)
|
||||
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null, String source = "email")
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null) return Unauthorized();
|
||||
|
|
@ -1532,7 +1634,7 @@ public class Controller : ControllerBase
|
|||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||
$"{report.MonthCount} {s.CountLabel}", s);
|
||||
$"{report.MonthCount} {s.CountLabel}", s, source: source);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
|
|
@ -2134,11 +2236,36 @@ public class Controller : ControllerBase
|
|||
});
|
||||
}
|
||||
|
||||
var assigneeChanged = ticket.AssigneeId != existing.AssigneeId
|
||||
&& ticket.AssigneeId.HasValue;
|
||||
|
||||
if (assigneeChanged)
|
||||
{
|
||||
var assignee = Db.GetUserById(ticket.AssigneeId);
|
||||
|
||||
Db.Create(new TicketTimelineEvent
|
||||
{
|
||||
TicketId = ticket.Id,
|
||||
EventType = (Int32)TimelineEventType.Assigned,
|
||||
Description = $"Ticket assigned to {assignee?.Name ?? "unknown"}.",
|
||||
ActorType = (Int32)TimelineActorType.Human,
|
||||
ActorId = user.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (assignee is not null)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email: {ex}"); }
|
||||
});
|
||||
}
|
||||
|
||||
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
|
||||
}
|
||||
|
||||
[HttpDelete(nameof(DeleteTicket))]
|
||||
public ActionResult DeleteTicket(Int64 id, Token authToken)
|
||||
public async Task<ActionResult> DeleteTicket(Int64 id, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user is null || user.UserType != 2) return Unauthorized();
|
||||
|
|
@ -2146,6 +2273,14 @@ public class Controller : ControllerBase
|
|||
var ticket = Db.GetTicketById(id);
|
||||
if (ticket is null) return NotFound();
|
||||
|
||||
// Clean up S3 objects for ticket documents before DB delete
|
||||
var s3Keys = Db.GetS3KeysForTicketDocuments(id);
|
||||
if (s3Keys.Count > 0)
|
||||
{
|
||||
try { await DocumentBucket.DeleteObjects(s3Keys); }
|
||||
catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on ticket delete failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed.");
|
||||
}
|
||||
|
||||
|
|
@ -2309,4 +2444,226 @@ public class Controller : ControllerBase
|
|||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPut(nameof(AcknowledgeTerms))]
|
||||
public ActionResult AcknowledgeTerms(Int32 version, Token authToken)
|
||||
{
|
||||
var session = Db.GetSession(authToken);
|
||||
if (session is null) return Unauthorized();
|
||||
|
||||
var user = Db.GetUserById(session.User.Id);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.AcknowledgedTermsVersion = version;
|
||||
return Db.Update(user) ? Ok() : StatusCode(500);
|
||||
}
|
||||
|
||||
// ── Document Upload/Download ────────────────────────────────────────
|
||||
|
||||
private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"application/pdf", "application/x-pdf"
|
||||
};
|
||||
|
||||
// Some browsers send generic MIME types — allow them if the file extension is valid
|
||||
private static readonly HashSet<String> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf"
|
||||
};
|
||||
|
||||
private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
private static S3Bucket DocumentBucket
|
||||
{
|
||||
get
|
||||
{
|
||||
var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials);
|
||||
return region.Bucket(Program.DocumentBucketName);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost(nameof(UploadDocument))]
|
||||
[RequestSizeLimit(26_214_400)]
|
||||
public async Task<ActionResult<Document>> UploadDocument(
|
||||
IFormFile file,
|
||||
[FromQuery] Int32 scope,
|
||||
[FromQuery] Int64? ticketId,
|
||||
[FromQuery] Int64? ticketCommentId,
|
||||
[FromQuery] Int64? installationId,
|
||||
[FromQuery] Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (file.Length == 0)
|
||||
return BadRequest("File is empty.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return BadRequest($"File exceeds maximum size of {MaxFileSizeBytes / (1024 * 1024)} MB.");
|
||||
|
||||
var fileExtension = Path.GetExtension(file.FileName);
|
||||
if (!AllowedMimeTypes.Contains(file.ContentType) && !AllowedExtensions.Contains(fileExtension))
|
||||
{
|
||||
Console.WriteLine($"[Documents] Rejected upload: name={file.FileName}, contentType={file.ContentType}, ext={fileExtension}, size={file.Length}");
|
||||
return BadRequest($"File type '{file.ContentType}' ({fileExtension}) is not allowed.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Documents] Accepting upload: name={file.FileName}, contentType={file.ContentType}, size={file.Length}");
|
||||
|
||||
// Validate parent entity exists
|
||||
var docScope = (DocumentScope)scope;
|
||||
String s3Prefix;
|
||||
|
||||
switch (docScope)
|
||||
{
|
||||
case DocumentScope.TicketAttachment:
|
||||
if (ticketId.HasValue)
|
||||
{
|
||||
if (Db.GetTicketById(ticketId.Value) is null) return NotFound("Ticket not found.");
|
||||
s3Prefix = $"tickets/{ticketId.Value}";
|
||||
}
|
||||
else if (ticketCommentId.HasValue)
|
||||
{
|
||||
s3Prefix = $"comments/{ticketCommentId.Value}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest("Ticket attachment requires ticketId or ticketCommentId.");
|
||||
}
|
||||
break;
|
||||
|
||||
case DocumentScope.InstallationDocument:
|
||||
if (!installationId.HasValue)
|
||||
return BadRequest("Installation document requires installationId.");
|
||||
if (Db.GetInstallationById(installationId.Value) is null)
|
||||
return NotFound("Installation not found.");
|
||||
s3Prefix = $"installations/{installationId.Value}";
|
||||
break;
|
||||
|
||||
default:
|
||||
return BadRequest("Invalid scope.");
|
||||
}
|
||||
|
||||
var guid = Guid.NewGuid().ToString("N");
|
||||
var safeFileName = Path.GetFileName(file.FileName);
|
||||
var s3Key = $"{s3Prefix}/{guid}/{safeFileName}";
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var s3Url = DocumentBucket.Path(s3Key);
|
||||
var success = await s3Url.PutObject(stream);
|
||||
|
||||
if (!success)
|
||||
return StatusCode(500, "Failed to upload file to storage.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Documents] Upload failed: {ex.Message}");
|
||||
return StatusCode(500, "Failed to upload file to storage.");
|
||||
}
|
||||
|
||||
var document = new Document
|
||||
{
|
||||
TicketId = ticketId,
|
||||
TicketCommentId = ticketCommentId,
|
||||
InstallationId = installationId,
|
||||
Scope = scope,
|
||||
S3Key = s3Key,
|
||||
OriginalName = safeFileName,
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
UploadedByUserId = user.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!Db.Create(document))
|
||||
return StatusCode(500, "Failed to save document metadata.");
|
||||
|
||||
return Ok(document);
|
||||
}
|
||||
|
||||
[HttpGet(nameof(DownloadDocument))]
|
||||
public async Task<ActionResult> DownloadDocument(Int64 id, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var document = Db.GetDocumentById(id);
|
||||
if (document is null) return NotFound("Document not found.");
|
||||
|
||||
// Access control: admin can access all; others need installation access
|
||||
if (user.UserType != 2 && document.InstallationId.HasValue)
|
||||
{
|
||||
var inst = Db.GetInstallationById(document.InstallationId.Value);
|
||||
if (inst is null || !user.HasAccessTo(inst)) return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var s3Url = DocumentBucket.Path(document.S3Key);
|
||||
var data = await s3Url.GetObject();
|
||||
return File(data.ToArray(), document.ContentType, document.OriginalName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Documents] Download failed for {document.S3Key}: {ex.Message}");
|
||||
return StatusCode(500, "Failed to download file from storage.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetDocuments))]
|
||||
public ActionResult<IEnumerable<Document>> GetDocuments(
|
||||
[FromQuery] Int64? ticketId,
|
||||
[FromQuery] Int64? ticketCommentId,
|
||||
[FromQuery] Int64? installationId,
|
||||
[FromQuery] Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (ticketId.HasValue)
|
||||
return Ok(Db.GetDocumentsForTicket(ticketId.Value));
|
||||
|
||||
if (ticketCommentId.HasValue)
|
||||
return Ok(Db.GetDocumentsForComment(ticketCommentId.Value));
|
||||
|
||||
if (installationId.HasValue)
|
||||
{
|
||||
// Access control: admin can list all; others need installation access
|
||||
if (user.UserType != 2)
|
||||
{
|
||||
var inst = Db.GetInstallationById(installationId.Value);
|
||||
if (inst is null || !user.HasAccessTo(inst)) return Unauthorized();
|
||||
}
|
||||
return Ok(Db.GetDocumentsForInstallation(installationId.Value));
|
||||
}
|
||||
|
||||
return BadRequest("Provide ticketId, ticketCommentId, or installationId.");
|
||||
}
|
||||
|
||||
[HttpDelete(nameof(DeleteDocument))]
|
||||
public async Task<ActionResult> DeleteDocument(Int64 id, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user is null || user.UserType != 2) return Unauthorized();
|
||||
|
||||
var document = Db.GetDocumentById(id);
|
||||
if (document is null) return NotFound("Document not found.");
|
||||
|
||||
try
|
||||
{
|
||||
await DocumentBucket.DeleteObjects(new[] { document.S3Key });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Documents] S3 delete failed for {document.S3Key}: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!Db.Delete(document))
|
||||
return StatusCode(500, "Failed to delete document metadata.");
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public enum DocumentScope
|
||||
{
|
||||
TicketAttachment = 0,
|
||||
InstallationDocument = 1
|
||||
}
|
||||
|
||||
public class Document
|
||||
{
|
||||
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||
|
||||
[Indexed] public Int64? TicketId { get; set; }
|
||||
[Indexed] public Int64? TicketCommentId { get; set; }
|
||||
[Indexed] public Int64? InstallationId { get; set; }
|
||||
|
||||
public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment;
|
||||
public String S3Key { get; set; } = "";
|
||||
public String OriginalName { get; set; } = "";
|
||||
public String ContentType { get; set; } = "";
|
||||
public Int64 SizeBytes { get; set; }
|
||||
public Int64 UploadedByUserId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public class EmailPreference
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Int64 InstallationId { get; set; }
|
||||
public Boolean SendWeekly { get; set; }
|
||||
public Boolean SendMonthly { get; set; }
|
||||
public Boolean SendYearly { get; set; }
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ public enum ProductType
|
|||
Salidomo = 1,
|
||||
SodioHome =2,
|
||||
SodiStoreMax=3,
|
||||
SodistoreGrid=4
|
||||
SodistoreGrid=4,
|
||||
SodistorePro=5
|
||||
}
|
||||
|
||||
public enum StatusType
|
||||
|
|
@ -27,6 +28,13 @@ public class Installation : TreeNode
|
|||
public String Location { get; set; } = "";
|
||||
public String Region { get; set; } = "";
|
||||
public String Country { get; set; } = "";
|
||||
public String Street { get; set; } = "";
|
||||
public String PostCode { get; set; } = "";
|
||||
public String City { get; set; } = "";
|
||||
public String Canton { get; set; } = "";
|
||||
public String DistributionPartner { get; set; } = "";
|
||||
public String InverterFirmwareVersion { get; set; } = "";
|
||||
public String BatteryFirmwareVersion { get; set; } = "";
|
||||
public String VpnIp { get; set; } = "";
|
||||
public String InstallationName { get; set; } = "";
|
||||
|
||||
|
|
@ -52,10 +60,12 @@ 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";
|
||||
|
||||
[Ignore]
|
||||
public String OrderNumbers { get; set; }
|
||||
public String VrmLink { get; set; } = "";
|
||||
public string Configuration { get; set; } = "";
|
||||
public string NetworkProvider { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
|
|
@ -146,6 +146,7 @@ public static class ExoCmd
|
|||
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
|
||||
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
|
||||
|
||||
|
||||
|
|
@ -350,6 +351,7 @@ public static class ExoCmd
|
|||
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
|
||||
installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
|
||||
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
|
||||
|
||||
var contentString = $$"""
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public static class InstallationMethods
|
|||
private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e";
|
||||
private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa";
|
||||
private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8";
|
||||
private static readonly String SodistoreProBucketNameSalt = "325c9373-9025-4a8d-bf5a-f9eedf1f155c";
|
||||
|
||||
public static String BucketName(this Installation installation)
|
||||
{
|
||||
|
|
@ -29,6 +30,11 @@ public static class InstallationMethods
|
|||
return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}";
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
return $"{installation.S3BucketId}-{SodistoreProBucketNameSalt}";
|
||||
}
|
||||
|
||||
return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}";
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ public static class SessionMethods
|
|||
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid)
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
return user is not null
|
||||
&& user.UserType != 0
|
||||
|
|
@ -295,7 +295,7 @@ public static class SessionMethods
|
|||
.Apply(Db.Update);
|
||||
}
|
||||
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid)
|
||||
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
|
||||
return user is not null
|
||||
|
|
|
|||
|
|
@ -243,22 +243,22 @@ public static class UserMethods
|
|||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
"Passwort Ihres Inesco Energy Kontos zurücksetzen",
|
||||
"Passwort Ihres inesco energy Kontos zurücksetzen",
|
||||
$"Sehr geehrte/r {user.Name}\n" +
|
||||
$"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}"
|
||||
),
|
||||
"fr" => (
|
||||
"Réinitialisation du mot de passe de votre compte Inesco Energy",
|
||||
"Réinitialisation du mot de passe de votre compte inesco energy",
|
||||
$"Cher/Chère {user.Name}\n" +
|
||||
$"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}"
|
||||
),
|
||||
"it" => (
|
||||
"Reimposta la password del tuo account Inesco Energy",
|
||||
"Reimposta la password del tuo account inesco energy",
|
||||
$"Gentile {user.Name}\n" +
|
||||
$"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}"
|
||||
),
|
||||
_ => (
|
||||
"Reset the password of your Inesco Energy Account",
|
||||
"Reset the password of your inesco energy Account",
|
||||
$"Dear {user.Name}\n" +
|
||||
$"To reset your password please open this link: {resetLink}?token={encodedToken}"
|
||||
)
|
||||
|
|
@ -274,28 +274,89 @@ public static class UserMethods
|
|||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
"Ihr neues Inesco Energy Konto",
|
||||
"Ihr neues inesco energy Konto",
|
||||
$"Sehr geehrte/r {user.Name}\n" +
|
||||
$"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
|
||||
$"Um Ihr Passwort festzulegen und sich bei Ihrem inesco energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
|
||||
),
|
||||
"fr" => (
|
||||
"Votre nouveau compte Inesco Energy",
|
||||
"Votre nouveau compte inesco energy",
|
||||
$"Cher/Chère {user.Name}\n" +
|
||||
$"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}"
|
||||
$"Pour définir votre mot de passe et vous connecter à votre compte inesco energy, veuillez ouvrir ce lien : {resetLink}"
|
||||
),
|
||||
"it" => (
|
||||
"Il tuo nuovo account Inesco Energy",
|
||||
"Il tuo nuovo account inesco energy",
|
||||
$"Gentile {user.Name}\n" +
|
||||
$"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}"
|
||||
$"Per impostare la password e accedere al suo account inesco energy, apra questo link: {resetLink}"
|
||||
),
|
||||
_ => (
|
||||
"Your new Inesco Energy Account",
|
||||
"Your new inesco energy Account",
|
||||
$"Dear {user.Name}\n" +
|
||||
$"To set your password and log in to your Inesco Energy Account open this link: {resetLink}"
|
||||
$"To set your password and log in to your inesco energy Account open this link: {resetLink}"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
|
||||
public static Task SendTicketAssignedEmail(this User user, Ticket ticket)
|
||||
{
|
||||
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||
var priority = (TicketPriority)ticket.Priority;
|
||||
var category = (TicketCategory)ticket.Category;
|
||||
|
||||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
$"inesco energy – Ticket #{ticket.Id} wurde Ihnen zugewiesen",
|
||||
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||
$"Ein Ticket wurde Ihnen zugewiesen:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Betreff: {ticket.Subject}\n" +
|
||||
$"Priorität: {priority}\n" +
|
||||
$"Kategorie: {category}\n\n" +
|
||||
$"Beschreibung:\n{ticket.Description}\n\n" +
|
||||
$"Öffnen Sie das Ticket hier: {ticketLink}\n\n" +
|
||||
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||
),
|
||||
"fr" => (
|
||||
$"inesco energy – Le ticket #{ticket.Id} vous a été attribué",
|
||||
$"Cher/Chère {user.Name},\n\n" +
|
||||
$"Un ticket vous a été attribué :\n\n" +
|
||||
$"Ticket : #{ticket.Id}\n" +
|
||||
$"Objet : {ticket.Subject}\n" +
|
||||
$"Priorité : {priority}\n" +
|
||||
$"Catégorie : {category}\n\n" +
|
||||
$"Description :\n{ticket.Description}\n\n" +
|
||||
$"Ouvrir le ticket : {ticketLink}\n\n" +
|
||||
"Cordialement,\ninesco energy Monitor"
|
||||
),
|
||||
"it" => (
|
||||
$"inesco energy – Il ticket #{ticket.Id} le è stato assegnato",
|
||||
$"Gentile {user.Name},\n\n" +
|
||||
$"Le è stato assegnato un ticket:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Oggetto: {ticket.Subject}\n" +
|
||||
$"Priorità: {priority}\n" +
|
||||
$"Categoria: {category}\n\n" +
|
||||
$"Descrizione:\n{ticket.Description}\n\n" +
|
||||
$"Aprire il ticket: {ticketLink}\n\n" +
|
||||
"Cordiali saluti,\ninesco energy Monitor"
|
||||
),
|
||||
_ => (
|
||||
$"inesco energy – Ticket #{ticket.Id} has been assigned to you",
|
||||
$"Dear {user.Name},\n\n" +
|
||||
$"A ticket has been assigned to you:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Subject: {ticket.Subject}\n" +
|
||||
$"Priority: {priority}\n" +
|
||||
$"Category: {category}\n\n" +
|
||||
$"Description:\n{ticket.Description}\n\n" +
|
||||
$"Open the ticket: {ticketLink}\n\n" +
|
||||
"Best regards,\ninesco energy Monitor"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -94,6 +94,11 @@ public class MonthlyReportSummary
|
|||
public Int32 WeekCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
|
||||
// Preview-only fields (not stored in DB)
|
||||
[Ignore] public Boolean IsPreview { get; set; }
|
||||
[Ignore] public Int32 DaysAvailable { get; set; }
|
||||
[Ignore] public Int32 DaysInMonth { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -137,19 +142,22 @@ public class YearlyReportSummary
|
|||
public Int32 MonthCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
|
||||
// Preview-only fields (not stored in DB)
|
||||
[Ignore] public Boolean IsPreview { get; set; }
|
||||
}
|
||||
|
||||
// ── DTOs for pending aggregation queries (not stored in DB) ──
|
||||
|
||||
public class PendingMonth
|
||||
{
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 Month { get; set; }
|
||||
public Int32 WeekCount { get; set; }
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 Month { get; set; }
|
||||
public Int32 WeekCount { get; set; }
|
||||
}
|
||||
|
||||
public class PendingYear
|
||||
{
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 MonthCount { get; set; }
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 MonthCount { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public class User : TreeNode
|
|||
public Boolean MustResetPassword { get; set; } = false;
|
||||
public String? Password { get; set; } = null!;
|
||||
public String Language { get; set; } = "en";
|
||||
public Int32? AcknowledgedTermsVersion { get; set; }
|
||||
|
||||
[Unique]
|
||||
public override String Name { get; set; } = null!;
|
||||
|
|
|
|||
|
|
@ -75,11 +75,21 @@ public static partial class Db
|
|||
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
||||
public static Boolean Create(AiInsightCache cache) => Insert(cache);
|
||||
|
||||
public static Boolean UpsertEmailPreference(EmailPreference pref)
|
||||
{
|
||||
var success = Connection.InsertOrReplace(pref) > 0;
|
||||
if (success) Backup();
|
||||
return success;
|
||||
}
|
||||
|
||||
// Ticket system
|
||||
public static Boolean Create(Ticket ticket) => Insert(ticket);
|
||||
public static Boolean Create(TicketComment comment) => Insert(comment);
|
||||
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||
|
||||
// Document storage
|
||||
public static Boolean Create(Document document) => Insert(document);
|
||||
|
||||
public static void HandleAction(UserAction newAction)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public static partial class Db
|
|||
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
|
||||
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
||||
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
|
||||
public static TableQuery<EmailPreference> EmailPreferences => Connection.Table<EmailPreference>();
|
||||
|
||||
// Ticket system tables
|
||||
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
|
||||
|
|
@ -38,6 +39,9 @@ public static partial class Db
|
|||
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
||||
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||
|
||||
// Document storage
|
||||
public static TableQuery<Document> Documents => Connection.Table<Document>();
|
||||
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
|
|
@ -69,12 +73,16 @@ public static partial class Db
|
|||
Connection.CreateTable<DailyEnergyRecord>();
|
||||
Connection.CreateTable<HourlyEnergyRecord>();
|
||||
Connection.CreateTable<AiInsightCache>();
|
||||
Connection.CreateTable<EmailPreference>();
|
||||
|
||||
// Ticket system tables
|
||||
Connection.CreateTable<Ticket>();
|
||||
Connection.CreateTable<TicketComment>();
|
||||
Connection.CreateTable<TicketAiDiagnosis>();
|
||||
Connection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
// Document storage
|
||||
Connection.CreateTable<Document>();
|
||||
});
|
||||
|
||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||
|
|
@ -83,10 +91,12 @@ public static partial class Db
|
|||
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
|
||||
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
|
||||
|
||||
// One-time migration: rebrand to inesco Energy
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'");
|
||||
// One-time migration: rebrand to inesco energy
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
|
||||
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
|
||||
Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'");
|
||||
Connection.Execute("UPDATE User SET Name = 'inesco Energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
|
||||
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
|
||||
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'");
|
||||
|
||||
//UpdateKeys();
|
||||
CleanupSessions().SupressAwaitWarning();
|
||||
|
|
@ -123,6 +133,7 @@ public static partial class Db
|
|||
fileConnection.CreateTable<DailyEnergyRecord>();
|
||||
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||
fileConnection.CreateTable<AiInsightCache>();
|
||||
fileConnection.CreateTable<EmailPreference>();
|
||||
|
||||
// Ticket system tables
|
||||
fileConnection.CreateTable<Ticket>();
|
||||
|
|
@ -130,6 +141,9 @@ public static partial class Db
|
|||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
// Document storage
|
||||
fileConnection.CreateTable<Document>();
|
||||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -129,12 +129,22 @@ public static partial class Db
|
|||
.Select(t => t.Id).ToList();
|
||||
foreach (var tid in ticketIds)
|
||||
{
|
||||
// Delete documents attached to ticket comments
|
||||
var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList();
|
||||
foreach (var cid in tCommentIds)
|
||||
Documents.Delete(d => d.TicketCommentId == cid);
|
||||
|
||||
// Delete documents attached directly to the ticket
|
||||
Documents .Delete(d => d.TicketId == tid);
|
||||
TicketComments .Delete(c => c.TicketId == tid);
|
||||
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
|
||||
TicketTimelineEvents.Delete(e => e.TicketId == tid);
|
||||
}
|
||||
Tickets.Delete(t => t.InstallationId == installation.Id);
|
||||
|
||||
// Clean up installation-level documents
|
||||
Documents.Delete(d => d.InstallationId == installation.Id);
|
||||
|
||||
return Installations.Delete(i => i.Id == installation.Id) > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +228,17 @@ public static partial class Db
|
|||
|
||||
Boolean DeleteTicketAndChildren()
|
||||
{
|
||||
// Delete documents attached to comments on this ticket
|
||||
var commentIds = TicketComments
|
||||
.Where(c => c.TicketId == ticket.Id)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
foreach (var cid in commentIds)
|
||||
Documents.Delete(d => d.TicketCommentId == cid);
|
||||
|
||||
// Delete documents attached directly to the ticket
|
||||
Documents .Delete(d => d.TicketId == ticket.Id);
|
||||
|
||||
TicketComments .Delete(c => c.TicketId == ticket.Id);
|
||||
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
|
||||
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
|
||||
|
|
@ -225,6 +246,39 @@ public static partial class Db
|
|||
}
|
||||
}
|
||||
|
||||
public static Boolean Delete(Document document)
|
||||
{
|
||||
var success = Documents.Delete(d => d.Id == document.Id) > 0;
|
||||
if (success) Backup();
|
||||
return success;
|
||||
}
|
||||
|
||||
public static List<String> GetS3KeysForTicketDocuments(Int64 ticketId)
|
||||
{
|
||||
// Get documents attached directly to the ticket
|
||||
var keys = Documents
|
||||
.Where(d => d.TicketId == ticketId)
|
||||
.Select(d => d.S3Key)
|
||||
.ToList();
|
||||
|
||||
// Also get documents attached to comments on this ticket
|
||||
var commentIds = TicketComments
|
||||
.Where(c => c.TicketId == ticketId)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var cid in commentIds)
|
||||
{
|
||||
var commentKeys = Documents
|
||||
.Where(d => d.TicketCommentId == cid)
|
||||
.Select(d => d.S3Key)
|
||||
.ToList();
|
||||
keys.AddRange(commentKeys);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
||||
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@ public static partial class Db
|
|||
&& c.Language == language)
|
||||
?.InsightText;
|
||||
|
||||
// ── EmailPreference Queries ─────────────────────────────────────────
|
||||
|
||||
public static EmailPreference? GetEmailPreference(Int64 installationId)
|
||||
=> EmailPreferences.FirstOrDefault(p => p.InstallationId == installationId);
|
||||
|
||||
// ── Ticket Queries ──────────────────────────────────────────────────
|
||||
|
||||
public static Ticket? GetTicketById(Int64 id)
|
||||
|
|
@ -205,4 +210,27 @@ public static partial class Db
|
|||
.Distinct()
|
||||
.OrderBy(s => s)
|
||||
.ToList();
|
||||
|
||||
// ── Document Queries ────────────────────────────────────────────────
|
||||
|
||||
public static Document? GetDocumentById(Int64 id)
|
||||
=> Documents.FirstOrDefault(d => d.Id == id);
|
||||
|
||||
public static List<Document> GetDocumentsForTicket(Int64 ticketId)
|
||||
=> Documents
|
||||
.Where(d => d.TicketId == ticketId)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
public static List<Document> GetDocumentsForComment(Int64 commentId)
|
||||
=> Documents
|
||||
.Where(d => d.TicketCommentId == commentId)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
public static List<Document> GetDocumentsForInstallation(Int64 installationId)
|
||||
=> Documents
|
||||
.Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
|
@ -3,6 +3,6 @@
|
|||
"SmtpUsername" : "no-reply@inesco.ch",
|
||||
"SmtpPassword" : "1ci4vi%+bfccIp",
|
||||
"SmtpPort" : 587,
|
||||
"SenderName" : "Inesco Energy",
|
||||
"SenderName" : "inesco energy",
|
||||
"SenderAddress" : "no-reply@inesco.ch"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ using InnovEnergy.App.Backend.DeleteOldData;
|
|||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||
using InnovEnergy.Lib.S3Utils;
|
||||
using InnovEnergy.Lib.S3Utils.DataTypes;
|
||||
using InnovEnergy.Lib.Utils;
|
||||
|
||||
namespace InnovEnergy.App.Backend;
|
||||
|
|
@ -26,6 +29,7 @@ public static class Program
|
|||
Watchdog.NotifyReady();
|
||||
Db.Init();
|
||||
LoadEnvFile();
|
||||
EnsureDocumentBucketExists().SupressAwaitWarning();
|
||||
DiagnosticService.Initialize();
|
||||
TicketDiagnosticService.Initialize();
|
||||
NetworkProviderService.Initialize();
|
||||
|
|
@ -122,6 +126,30 @@ public static class Program
|
|||
}
|
||||
}
|
||||
|
||||
public const String DocumentBucketName = "inesco-documents";
|
||||
|
||||
private static async Task EnsureDocumentBucketExists()
|
||||
{
|
||||
try
|
||||
{
|
||||
var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials);
|
||||
var buckets = await region.ListAllBuckets();
|
||||
if (buckets.Buckets.All(b => b.BucketName != DocumentBucketName))
|
||||
{
|
||||
await region.PutBucket(DocumentBucketName);
|
||||
Console.WriteLine($"[Documents] Created S3 bucket: {DocumentBucketName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Documents] S3 bucket already exists: {DocumentBucketName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Documents] Warning: Could not ensure bucket exists: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo
|
||||
{
|
||||
Title = "Inesco Backend API",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public class Session : Relation<String, Int64>
|
|||
public Boolean AccessToSodistoreMax { get; set; } = false;
|
||||
public Boolean AccessToSodioHome { get; set; } = false;
|
||||
public Boolean AccessToSodistoreGrid { get; set; } = false;
|
||||
public Boolean AccessToSodistorePro { get; set; } = false;
|
||||
[Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ;
|
||||
|
||||
// Private backing field
|
||||
|
|
@ -51,6 +52,7 @@ public class Session : Relation<String, Int64>
|
|||
AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0;
|
||||
AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0;
|
||||
AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0;
|
||||
AccessToSodistorePro = user.AccessibleInstallations(product: (int)ProductType.SodistorePro).ToList().Count > 0;
|
||||
|
||||
Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count);
|
||||
Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
|
|
@ -115,6 +116,7 @@ public static class AggregatedJsonParser
|
|||
/// Tries to read an aggregated JSON file from the installation's S3 bucket.
|
||||
/// S3 key: DDMMYYYY.json (directly in bucket root).
|
||||
/// Returns file content or null if not found / error.
|
||||
/// Handles base64-encoded files (SinexcelCommunication uploads base64).
|
||||
/// </summary>
|
||||
public static async Task<String?> TryReadFromS3(Installation installation, String isoDate)
|
||||
{
|
||||
|
|
@ -125,7 +127,8 @@ public static class AggregatedJsonParser
|
|||
var bucket = region.Bucket(installation.BucketName());
|
||||
var s3Url = bucket.Path(fileName);
|
||||
|
||||
return await s3Url.GetObjectAsString();
|
||||
var raw = await s3Url.GetObjectAsString();
|
||||
return DecodeContent(raw);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -134,6 +137,29 @@ public static class AggregatedJsonParser
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes S3 file content. SinexcelCommunication devices upload DDMMYYYY.json
|
||||
/// as base64-encoded NDJSON. If the content doesn't start with '{' it is
|
||||
/// assumed to be base64 and decoded accordingly.
|
||||
/// </summary>
|
||||
private static String DecodeContent(String raw)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.StartsWith('{'))
|
||||
return raw;
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(trimmed));
|
||||
Console.WriteLine("[AggregatedJsonParser] Decoded base64-encoded S3 content");
|
||||
return decoded;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON DTOs ---
|
||||
|
||||
private sealed class HourlyJsonDto
|
||||
|
|
|
|||
|
|
@ -1223,7 +1223,7 @@ textarea:focus{outline:none;border-color:#3498db}
|
|||
<div id="app"></div>
|
||||
<div class="nav" id="nav"></div>
|
||||
<div class="thankyou">Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</div>
|
||||
<div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco Energy Monitor</div>
|
||||
<div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco energy Monitor</div>
|
||||
<script>
|
||||
var ALARMS = %%ALARMS_JSON%%;
|
||||
var SUBMIT_URL = "%%SUBMIT_URL%%";
|
||||
|
|
@ -1473,7 +1473,7 @@ render();
|
|||
<p style="margin-bottom:0;font-size:13px;color:#555">Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</p>
|
||||
</td></tr>
|
||||
<tr><td style="background:#ecf0f1;padding:12px 28px;text-align:center;font-size:11px;color:#888;border-top:1px solid #ddd">
|
||||
inesco Energy Monitor
|
||||
inesco energy Monitor
|
||||
</td></tr>
|
||||
</table></td></tr></table></body></html>
|
||||
""";
|
||||
|
|
@ -1545,7 +1545,7 @@ render();
|
|||
<p>Hallo <strong>{name}</strong>,</p>
|
||||
<p style="margin-top:12px">Kurze Erinnerung — die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p>
|
||||
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen →</a></p>
|
||||
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
|
||||
<p style="font-size:11px;color:#bbb">inesco energy Monitor</p>
|
||||
</body></html>
|
||||
""";
|
||||
await SendEmailAsync(email, subject, html);
|
||||
|
|
@ -1645,7 +1645,7 @@ render();
|
|||
<table style="border-collapse:collapse;width:100%">
|
||||
{beforeAfterRows}
|
||||
</table>
|
||||
<p style="margin-top:18px;font-size:11px;color:#bbb">inesco Energy Monitor</p>
|
||||
<p style="margin-top:18px;font-size:11px;color:#bbb">inesco energy Monitor</p>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ public static class DailyIngestionService
|
|||
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
|
||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
||||
.ToList();
|
||||
|
||||
foreach (var installation in installations)
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ public static class ReportAggregationService
|
|||
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
|
||||
.Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
|
||||
.ToList();
|
||||
|
||||
var generated = 0;
|
||||
|
|
@ -121,6 +121,9 @@ public static class ReportAggregationService
|
|||
generated++;
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.Name})");
|
||||
|
||||
// Auto-send email if preference is set
|
||||
await TryAutoSendWeeklyEmail(installation, report);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -364,20 +367,23 @@ public static class ReportAggregationService
|
|||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
|
||||
|
||||
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
|
||||
var aiInsight = await GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, days.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language,
|
||||
installation?.Location, installation?.Country, installation?.Region);
|
||||
weatherCity, installation?.Country, weatherRegion);
|
||||
|
||||
var monthlySummary = new MonthlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
Month = month,
|
||||
PeriodStart = first.ToString("yyyy-MM-dd"),
|
||||
PeriodEnd = last.ToString("yyyy-MM-dd"),
|
||||
PeriodStart = days.Min(d => d.Date), // actual first data day, not calendar month start
|
||||
PeriodEnd = days.Max(d => d.Date), // actual last data day, not calendar month end
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
|
|
@ -413,6 +419,9 @@ public static class ReportAggregationService
|
|||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}–{last}).");
|
||||
|
||||
// Auto-send email if preference is set
|
||||
await TryAutoSendMonthlyEmail(installationId, monthlySummary);
|
||||
}
|
||||
|
||||
// ── Year-End Aggregation ──────────────────────────────────────────
|
||||
|
|
@ -526,6 +535,82 @@ public static class ReportAggregationService
|
|||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated).");
|
||||
|
||||
// Auto-send email if preference is set
|
||||
await TryAutoSendYearlyEmail(installationId, yearlySummary);
|
||||
}
|
||||
|
||||
// ── Auto-Send Email Helpers ─────────────────────────────────────────
|
||||
|
||||
private static async Task TryAutoSendWeeklyEmail(Installation installation, WeeklyReportResponse report)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pref = Db.GetEmailPreference(installation.Id);
|
||||
if (pref is not { SendWeekly: true }) return;
|
||||
|
||||
var email = installation.Email;
|
||||
if (String.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
Console.WriteLine($"[AutoSend] Weekly: skipping installation {installation.Id} — no email configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ReportEmailService.SendReportEmailAsync(report, email, "en", installation.Name);
|
||||
Console.WriteLine($"[AutoSend] Weekly email sent to {email} for installation {installation.Id}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AutoSend] Weekly email failed for installation {installation.Id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TryAutoSendMonthlyEmail(Int64 installationId, MonthlyReportSummary report)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pref = Db.GetEmailPreference(installationId);
|
||||
if (pref is not { SendMonthly: true }) return;
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var email = installation?.Email;
|
||||
if (String.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
Console.WriteLine($"[AutoSend] Monthly: skipping installation {installationId} — no email configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation!.Name, email, "en");
|
||||
Console.WriteLine($"[AutoSend] Monthly email sent to {email} for installation {installationId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AutoSend] Monthly email failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TryAutoSendYearlyEmail(Int64 installationId, YearlyReportSummary report)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pref = Db.GetEmailPreference(installationId);
|
||||
if (pref is not { SendYearly: true }) return;
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var email = installation?.Email;
|
||||
if (String.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
Console.WriteLine($"[AutoSend] Yearly: skipping installation {installationId} — no email configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ReportEmailService.SendYearlyReportEmailAsync(report, installation!.Name, email, "en");
|
||||
Console.WriteLine($"[AutoSend] Yearly email sent to {email} for installation {installationId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AutoSend] Yearly email failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI Insight Cache ──────────────────────────────────────────────
|
||||
|
|
@ -591,6 +676,8 @@ public static class ReportAggregationService
|
|||
var installationName = installation?.Name
|
||||
?? $"Installation {report.InstallationId}";
|
||||
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
|
||||
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
return GetOrGenerateInsightAsync("monthly", report.Id, language,
|
||||
() => GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, report.WeekCount,
|
||||
|
|
@ -599,7 +686,7 @@ public static class ReportAggregationService
|
|||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
|
||||
installation?.Location, installation?.Country, installation?.Region));
|
||||
weatherCity, installation?.Country, weatherRegion));
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
|
||||
|
|
@ -794,4 +881,132 @@ Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting ma
|
|||
|
||||
return "AI insight could not be generated at this time.";
|
||||
}
|
||||
|
||||
// ── Current-Period Previews (not saved to DB) ─────────────────────
|
||||
|
||||
public static async Task<MonthlyReportSummary?> GetCurrentMonthPreviewAsync(Int64 installationId, String language = "en")
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var year = now.Year;
|
||||
var month = now.Month;
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var days = Db.GetDailyRecords(installationId, first, last);
|
||||
|
||||
if (days.Count == 0)
|
||||
return null;
|
||||
|
||||
var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
|
||||
var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
|
||||
var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
|
||||
var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
|
||||
var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
|
||||
|
||||
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
|
||||
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
|
||||
var aiInsight = await GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, days.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language,
|
||||
weatherCity, installation?.Country, weatherRegion);
|
||||
|
||||
var firstDataDay = days.Min(d => d.Date);
|
||||
var lastDataDay = days.Max(d => d.Date);
|
||||
|
||||
return new MonthlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
Month = month,
|
||||
PeriodStart = firstDataDay,
|
||||
PeriodEnd = lastDataDay,
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
WeekCount = days.Count,
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
IsPreview = true,
|
||||
DaysAvailable = days.Count,
|
||||
DaysInMonth = last.Day,
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<YearlyReportSummary?> GetCurrentYearPreviewAsync(Int64 installationId, String language = "en")
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||
|
||||
if (monthlies.Count == 0)
|
||||
return null;
|
||||
|
||||
var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1);
|
||||
var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1);
|
||||
var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1);
|
||||
var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1);
|
||||
var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1);
|
||||
|
||||
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.Name ?? $"Installation {installationId}";
|
||||
|
||||
var aiInsight = await GenerateYearlyAiInsightAsync(
|
||||
installationName, year, monthlies.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language);
|
||||
|
||||
return new YearlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
PeriodStart = monthlies.Min(m => m.PeriodStart),
|
||||
PeriodEnd = monthlies.Max(m => m.PeriodEnd),
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
MonthCount = monthlies.Count,
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
IsPreview = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -40,6 +40,33 @@ public static class WeatherService
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns historical weather for a date range, or null on any failure.
|
||||
/// Uses Open-Meteo's archive API for past weather data.
|
||||
/// </summary>
|
||||
public static async Task<List<DailyWeather>?> GetHistoricalAsync(
|
||||
string? city, string? country, string? region,
|
||||
DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(city))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var coords = await GeocodeAsync(city, region);
|
||||
if (coords == null)
|
||||
return null;
|
||||
|
||||
var (lat, lon) = coords.Value;
|
||||
return await FetchHistoricalAsync(lat, lon, startDate, endDate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WeatherService] Error fetching historical weather for '{city}': {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a forecast list into a compact text block for AI prompt injection.
|
||||
/// </summary>
|
||||
|
|
@ -52,7 +79,22 @@ public static class WeatherService
|
|||
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||||
});
|
||||
|
||||
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
|
||||
return "WEATHER FORECAST (coming 7 days):\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats historical weather into a compact text block for AI prompt injection.
|
||||
/// </summary>
|
||||
public static string FormatHistoricalForPrompt(List<DailyWeather> historical)
|
||||
{
|
||||
var lines = historical.Select(d =>
|
||||
{
|
||||
var date = DateTime.Parse(d.Date);
|
||||
var dayName = date.ToString("ddd dd MMM");
|
||||
return $"- {dayName}: {d.TempMin:F0}–{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
|
||||
});
|
||||
|
||||
return "ACTUAL WEATHER (during reporting week):\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -145,6 +187,44 @@ public static class WeatherService
|
|||
return forecast;
|
||||
}
|
||||
|
||||
private static async Task<List<DailyWeather>?> FetchHistoricalAsync(double lat, double lon, DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
var url = $"https://archive-api.open-meteo.com/v1/archive"
|
||||
+ $"?latitude={lat}&longitude={lon}"
|
||||
+ $"&start_date={startDate:yyyy-MM-dd}&end_date={endDate:yyyy-MM-dd}"
|
||||
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
|
||||
+ "&timezone=Europe/Zurich";
|
||||
|
||||
var json = await url.GetStringAsync();
|
||||
var data = JsonConvert.DeserializeObject<dynamic>(json);
|
||||
|
||||
if (data?.daily == null)
|
||||
return null;
|
||||
|
||||
var dates = data.daily.time;
|
||||
var tempMax = data.daily.temperature_2m_max;
|
||||
var tempMin = data.daily.temperature_2m_min;
|
||||
var sun = data.daily.sunshine_duration;
|
||||
var precip = data.daily.precipitation_sum;
|
||||
var codes = data.daily.weathercode;
|
||||
|
||||
var historical = new List<DailyWeather>();
|
||||
for (int i = 0; i < dates.Count; i++)
|
||||
{
|
||||
historical.Add(new DailyWeather(
|
||||
Date: (string)dates[i],
|
||||
TempMin: (double)tempMin[i],
|
||||
TempMax: (double)tempMax[i],
|
||||
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
|
||||
PrecipitationMm: (double)precip[i],
|
||||
Description: WeatherCodeToDescription((int)codes[i])
|
||||
));
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WeatherService] Fetched {historical.Count}-day historical weather ({startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}).");
|
||||
return historical;
|
||||
}
|
||||
|
||||
private static string WeatherCodeToDescription(int code) => code switch
|
||||
{
|
||||
0 => "Clear sky",
|
||||
|
|
|
|||
|
|
@ -179,9 +179,9 @@ public static class WeeklyReportService
|
|||
|
||||
// 4. Get installation location for weather forecast
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var location = installation?.Location;
|
||||
var location = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
|
||||
var country = installation?.Country;
|
||||
var region = installation?.Region;
|
||||
var region = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
|
||||
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
|
||||
|
||||
return await GenerateReportFromDataAsync(
|
||||
|
|
@ -274,7 +274,8 @@ public static class WeeklyReportService
|
|||
var aiInsight = await GetAiInsightAsync(
|
||||
currentWeekDays, currentSummary, previousSummary,
|
||||
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
||||
behavior, installationName, language, location, country, region);
|
||||
behavior, installationName, language,
|
||||
weekStart, weekEnd, location, country, region);
|
||||
|
||||
// Compute data availability — which days of the week are missing
|
||||
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
|
||||
|
|
@ -356,6 +357,8 @@ public static class WeeklyReportService
|
|||
BehavioralPattern behavior,
|
||||
string installationName,
|
||||
string language = "en",
|
||||
DateOnly? periodStart = null,
|
||||
DateOnly? periodEnd = null,
|
||||
string? location = null,
|
||||
string? country = null,
|
||||
string? region = null)
|
||||
|
|
@ -367,7 +370,23 @@ public static class WeeklyReportService
|
|||
return "AI insight unavailable (API key not configured).";
|
||||
}
|
||||
|
||||
// Fetch weather forecast for the installation's location
|
||||
// Date labels for prompt clarity
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var periodLabel = periodStart.HasValue && periodEnd.HasValue
|
||||
? $"{periodStart.Value:MMM dd}–{periodEnd.Value:MMM dd}"
|
||||
: "the reporting week";
|
||||
|
||||
// Fetch historical weather for the report week (actual conditions)
|
||||
List<WeatherService.DailyWeather>? historical = null;
|
||||
var historicalBlock = "";
|
||||
if (periodStart.HasValue && periodEnd.HasValue)
|
||||
{
|
||||
historical = await WeatherService.GetHistoricalAsync(location, country, region, periodStart.Value, periodEnd.Value);
|
||||
historicalBlock = historical != null ? "\n" + WeatherService.FormatHistoricalForPrompt(historical) + "\n" : "";
|
||||
Console.WriteLine($"[WeeklyReportService] Historical weather: {(historical != null ? $"{historical.Count} days fetched" : "SKIPPED (no location or API error)")}");
|
||||
}
|
||||
|
||||
// Fetch weather forecast for the coming week
|
||||
var forecast = await WeatherService.GetForecastAsync(location, country, region);
|
||||
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
|
||||
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
|
||||
|
|
@ -399,22 +418,29 @@ public static class WeeklyReportService
|
|||
var battDepleteLine = hasBattery
|
||||
? (behavior.AvgBatteryDepletedHour >= 0
|
||||
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
|
||||
: "Battery stayed above 20% SoC every night this week.")
|
||||
: $"Battery stayed above 20% SoC every night during {periodLabel}.")
|
||||
: "";
|
||||
|
||||
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
|
||||
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
|
||||
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
|
||||
|
||||
// Look up actual weather for best/worst solar days (if historical data available)
|
||||
var bestDayWeather = historical?.FirstOrDefault(w => w.Date == bestDay.Date);
|
||||
var worstDayWeather = historical?.FirstOrDefault(w => w.Date == worstDay.Date);
|
||||
|
||||
var bestDayWeatherNote = bestDayWeather != null ? $" (actual weather: {bestDayWeather.Description}, {bestDayWeather.SunshineHours:F1}h sunshine)" : "";
|
||||
var worstDayWeatherNote = worstDayWeather != null ? $" (actual weather: {worstDayWeather.Description}, {worstDayWeather.SunshineHours:F1}h sunshine)" : "";
|
||||
|
||||
// Build conditional fact lines
|
||||
var pvDailyFact = hasPv
|
||||
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
|
||||
? $"- PV: total {current.TotalPvProduction:F1} kWh for {periodLabel}. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh){bestDayWeatherNote}, worst: {worstDayName} ({worstDay.PvProduction:F1} kWh){worstDayWeatherNote}. Solar covered {selfSufficiency}% of consumption."
|
||||
: "";
|
||||
var battDailyFact = hasBattery
|
||||
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
|
||||
: "";
|
||||
var gridDailyFact = hasGrid
|
||||
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
|
||||
? $"- Grid import: {current.TotalGridImport:F1} kWh total for {periodLabel}."
|
||||
: "";
|
||||
|
||||
// Behavioral section — only include when hourly data exists
|
||||
|
|
@ -432,7 +458,7 @@ public static class WeeklyReportService
|
|||
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
|
||||
|
||||
behavioralSection = $@"
|
||||
BEHAVIORAL PATTERN (from hourly data this week):
|
||||
BEHAVIORAL PATTERN (from hourly data for {periodLabel}):
|
||||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||||
- {weekdayWeekendLine}{pvBehaviorLines}
|
||||
{gridBehaviorLine}
|
||||
|
|
@ -440,12 +466,15 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
}
|
||||
|
||||
// Build conditional instructions
|
||||
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
|
||||
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that during {periodLabel}, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
|
||||
|
||||
var hasHistorical = historical != null && historical.Count > 0;
|
||||
var instruction2 = hasPv
|
||||
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
|
||||
? hasHistorical
|
||||
? $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Use the ACTUAL WEATHER data provided above to explain why — do NOT guess the weather. Reference the real conditions (sunshine hours, weather description) from the historical weather data."
|
||||
: $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Only state the production numbers — do NOT speculate about weather reasons if no weather data is provided."
|
||||
: hasGrid
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
|
||||
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid during {periodLabel}."
|
||||
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
|
||||
|
||||
var instruction3 = hasBattery
|
||||
|
|
@ -455,19 +484,24 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
// Instruction 4 — adapts based on whether we have behavioral data
|
||||
string instruction4;
|
||||
if (hasBehavior && hasPv)
|
||||
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
|
||||
instruction4 = $"4. Smart action for the coming week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
|
||||
else if (hasBehavior && hasGrid)
|
||||
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
|
||||
instruction4 = $"4. Smart action for the coming week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
|
||||
else
|
||||
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
|
||||
instruction4 = $"4. Smart action for the coming week: Based on the energy patterns from {periodLabel}, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
|
||||
|
||||
// Instruction 5 — weather outlook with pattern-based predictions
|
||||
var hasWeather = forecast != null;
|
||||
var bulletCount = hasWeather ? 5 : 4;
|
||||
// Forecast date range label for prompt
|
||||
var forecastLabel = forecast != null && forecast.Count > 0
|
||||
? $"{DateTime.Parse(forecast.First().Date):MMM dd}–{DateTime.Parse(forecast.Last().Date):MMM dd}"
|
||||
: "the coming days";
|
||||
|
||||
var instruction5 = "";
|
||||
if (hasWeather && hasPv)
|
||||
{
|
||||
// Compute avg daily PV production this week for reference
|
||||
// Compute avg daily PV production for the reporting week as reference
|
||||
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
|
||||
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
|
||||
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
|
||||
|
|
@ -477,36 +511,39 @@ BEHAVIORAL PATTERN (from hourly data this week):
|
|||
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
|
||||
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
|
||||
|
||||
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
|
||||
var patternContext = $"During {periodLabel} the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
|
||||
if (sunnyDays.Count > 0)
|
||||
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
|
||||
patternContext += $"In the coming days ({forecastLabel}), sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
|
||||
if (cloudyDays.Count > 0)
|
||||
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
|
||||
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
|
||||
patternContext += $"Total forecast sunshine for {forecastLabel}: {totalForecastSunshine}h.";
|
||||
|
||||
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
|
||||
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days in the coming days ({forecastLabel}) and estimate production based on the reporting week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
|
||||
}
|
||||
else if (hasWeather)
|
||||
{
|
||||
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
|
||||
instruction5 = $@"5. Weather outlook: Summarize the weather for the coming days ({forecastLabel}) in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
|
||||
}
|
||||
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
Today is {today:yyyy-MM-dd} ({today:dddd}). This report covers the week of {periodLabel}.
|
||||
|
||||
Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
|
||||
|
||||
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
|
||||
|
||||
CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number.
|
||||
CRITICAL: Use explicit date references. Say ""during {periodLabel}"" for the reporting week. Say ""the coming days ({forecastLabel})"" for the forecast period. NEVER use ambiguous terms like ""this week"" or ""next week"".
|
||||
|
||||
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
|
||||
|
||||
DAILY FACTS:
|
||||
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
|
||||
DAILY FACTS (for {periodLabel}):
|
||||
- Total consumption: {current.TotalConsumption:F1} kWh. Self-sufficiency: {selfSufficiency}%.
|
||||
{pvDailyFact}
|
||||
{battDailyFact}
|
||||
{gridDailyFact}
|
||||
{behavioralSection}
|
||||
{historicalBlock}
|
||||
{weatherBlock}
|
||||
INSTRUCTIONS:
|
||||
{instruction1}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ public static class RabbitMqManager
|
|||
monitorLink =
|
||||
$"https://monitor.inesco.energy/sodistoregrid_installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
else if (installation.Product == (int)ProductType.SodistorePro)
|
||||
{
|
||||
monitorLink =
|
||||
$"https://monitor.inesco.energy/sodistorepro_installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
else
|
||||
{
|
||||
monitorLink =
|
||||
|
|
@ -131,7 +136,7 @@ public static class RabbitMqManager
|
|||
Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
|
||||
string recipient = "support@innov.energy";
|
||||
string subject = $"Battery Alarm from {installation.Name}: 2 or more strings broken";
|
||||
string text = $"Dear inesco Energy Support Team,\n" +
|
||||
string text = $"Dear inesco energy Support Team,\n" +
|
||||
$"\n"+
|
||||
$"Installation Name: {installation.Name}\n"+
|
||||
$"\n"+
|
||||
|
|
@ -143,7 +148,7 @@ public static class RabbitMqManager
|
|||
$"\n"+
|
||||
$"Thank you for your great support:)";
|
||||
//Disable this function now
|
||||
//Mailer.Send("inesco Energy Support Team", recipient, subject, text);
|
||||
//Mailer.Send("inesco energy Support Team", recipient, subject, text);
|
||||
}
|
||||
//Create a new error and add it to the database
|
||||
Db.HandleError(newError, installationId);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ public static class WebsocketManager
|
|||
(installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2))
|
||||
(installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
|
||||
(installationConnection.Value.Product == (int)ProductType.SodistorePro && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4))
|
||||
)
|
||||
{
|
||||
Console.WriteLine("Installation ID is " + installationConnection.Key);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using InnovEnergy.App.SinexcelCommunication.ESS;
|
||||
|
||||
namespace InnovEnergy.App.SinexcelCommunication.AggregationService;
|
||||
|
||||
public class HourlyAccumulator
|
||||
{
|
||||
public DateTime HourStart { get; set; }
|
||||
|
||||
public double StartSelfGeneratedElectricity { get; set; }
|
||||
public double StartElectricityPurchased { get; set; }
|
||||
public double StartElectricityFed { get; set; }
|
||||
public double StartBatteryChargeEnergy { get; set; }
|
||||
public double StartBatteryDischargeEnergy { get; set; }
|
||||
public double StartLoadPowerConsumption { get; set; }
|
||||
|
||||
public double LastSelfGeneratedElectricity { get; set; }
|
||||
public double LastElectricityPurchased { get; set; }
|
||||
public double LastElectricityFed { get; set; }
|
||||
public double LastBatteryChargeEnergy { get; set; }
|
||||
public double LastBatteryDischargeEnergy { get; set; }
|
||||
public double LastLoadPowerConsumption { get; set; }
|
||||
}
|
||||
|
||||
public static class EnergyAggregation
|
||||
{
|
||||
private static HourlyAccumulator? _currentHourAccumulator;
|
||||
private static DateTime? _lastDailySaveDate;
|
||||
|
||||
|
||||
public static HourlyEnergyData? ProcessHourlyData(StatusRecord statusRecord, DateTime timestamp)
|
||||
{
|
||||
var r = statusRecord.InverterRecord;
|
||||
var hourStart = new DateTime(timestamp.Year, timestamp.Month, timestamp.Day, timestamp.Hour, 0, 0);
|
||||
|
||||
// First call
|
||||
if (_currentHourAccumulator == null)
|
||||
{
|
||||
_currentHourAccumulator = new HourlyAccumulator
|
||||
{
|
||||
HourStart = hourStart,
|
||||
|
||||
StartSelfGeneratedElectricity = r.SelfGeneratedElectricity,
|
||||
StartElectricityPurchased = r.ElectricityPurchased,
|
||||
StartElectricityFed = r.ElectricityFed,
|
||||
StartBatteryChargeEnergy = r.BatteryChargeEnergy,
|
||||
StartBatteryDischargeEnergy = r.BatteryDischargeEnergy,
|
||||
StartLoadPowerConsumption = r.LoadPowerConsumption,
|
||||
|
||||
LastSelfGeneratedElectricity = r.SelfGeneratedElectricity,
|
||||
LastElectricityPurchased = r.ElectricityPurchased,
|
||||
LastElectricityFed = r.ElectricityFed,
|
||||
LastBatteryChargeEnergy = r.BatteryChargeEnergy,
|
||||
LastBatteryDischargeEnergy = r.BatteryDischargeEnergy,
|
||||
LastLoadPowerConsumption = r.LoadPowerConsumption
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Still same hour → just update last values
|
||||
if (_currentHourAccumulator.HourStart == hourStart)
|
||||
{
|
||||
_currentHourAccumulator.LastSelfGeneratedElectricity = r.SelfGeneratedElectricity;
|
||||
_currentHourAccumulator.LastElectricityPurchased = r.ElectricityPurchased;
|
||||
_currentHourAccumulator.LastElectricityFed = r.ElectricityFed;
|
||||
_currentHourAccumulator.LastBatteryChargeEnergy = r.BatteryChargeEnergy;
|
||||
_currentHourAccumulator.LastBatteryDischargeEnergy = r.BatteryDischargeEnergy;
|
||||
_currentHourAccumulator.LastLoadPowerConsumption = r.LoadPowerConsumption;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hour changed → finalize previous hour
|
||||
var completedHour = new HourlyEnergyData
|
||||
{
|
||||
Timestamp = _currentHourAccumulator.HourStart,
|
||||
|
||||
SelfGeneratedElectricity = SafeDiff(
|
||||
_currentHourAccumulator.LastSelfGeneratedElectricity,
|
||||
_currentHourAccumulator.StartSelfGeneratedElectricity),
|
||||
|
||||
ElectricityPurchased = SafeDiff(
|
||||
_currentHourAccumulator.LastElectricityPurchased,
|
||||
_currentHourAccumulator.StartElectricityPurchased),
|
||||
|
||||
ElectricityFed = SafeDiff(
|
||||
_currentHourAccumulator.LastElectricityFed,
|
||||
_currentHourAccumulator.StartElectricityFed),
|
||||
|
||||
BatteryChargeEnergy = SafeDiff(
|
||||
_currentHourAccumulator.LastBatteryChargeEnergy,
|
||||
_currentHourAccumulator.StartBatteryChargeEnergy),
|
||||
|
||||
BatteryDischargeEnergy = SafeDiff(
|
||||
_currentHourAccumulator.LastBatteryDischargeEnergy,
|
||||
_currentHourAccumulator.StartBatteryDischargeEnergy),
|
||||
|
||||
LoadPowerConsumption = SafeDiff(
|
||||
_currentHourAccumulator.LastLoadPowerConsumption,
|
||||
_currentHourAccumulator.StartLoadPowerConsumption)
|
||||
};
|
||||
|
||||
// Start new hour with current sample
|
||||
_currentHourAccumulator = new HourlyAccumulator
|
||||
{
|
||||
HourStart = hourStart,
|
||||
|
||||
StartSelfGeneratedElectricity = r.SelfGeneratedElectricity,
|
||||
StartElectricityPurchased = r.ElectricityPurchased,
|
||||
StartElectricityFed = r.ElectricityFed,
|
||||
StartBatteryChargeEnergy = r.BatteryChargeEnergy,
|
||||
StartBatteryDischargeEnergy = r.BatteryDischargeEnergy,
|
||||
StartLoadPowerConsumption = r.LoadPowerConsumption,
|
||||
|
||||
LastSelfGeneratedElectricity = r.SelfGeneratedElectricity,
|
||||
LastElectricityPurchased = r.ElectricityPurchased,
|
||||
LastElectricityFed = r.ElectricityFed,
|
||||
LastBatteryChargeEnergy = r.BatteryChargeEnergy,
|
||||
LastBatteryDischargeEnergy = r.BatteryDischargeEnergy,
|
||||
LastLoadPowerConsumption = r.LoadPowerConsumption
|
||||
};
|
||||
|
||||
return completedHour;
|
||||
}
|
||||
|
||||
public static DailyEnergyData? TryCreateDailyData(StatusRecord statusRecord, DateTime timestamp)
|
||||
{
|
||||
if (timestamp is { Hour: 23, Minute: 59 })
|
||||
{
|
||||
if (_lastDailySaveDate != timestamp.Date)
|
||||
{
|
||||
_lastDailySaveDate = timestamp.Date;
|
||||
|
||||
var r = statusRecord.InverterRecord;
|
||||
|
||||
return new DailyEnergyData
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
|
||||
DailySelfGeneratedElectricity = r.DailySelfGeneratedElectricity,
|
||||
DailyElectricityPurchased = r.DailyElectricityPurchased,
|
||||
DailyElectricityFed = r.DailyElectricityFed,
|
||||
BatteryDailyChargeEnergy = r.BatteryDailyChargeEnergy,
|
||||
BatteryDailyDischargeEnergy = r.BatteryDailyDischargeEnergy,
|
||||
DailyLoadPowerConsumption = r.DailyLoadPowerConsumption
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double SafeDiff(double endValue, double startValue)
|
||||
{
|
||||
var diff = endValue - startValue;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ public class Configuration
|
|||
public Double MinimumSoC { get; set; }
|
||||
public Double MaximumDischargingCurrent { get; set; }
|
||||
public Double MaximumChargingCurrent { get; set; }
|
||||
public WorkingMode OperatingPriority { get; set; }
|
||||
public OperatingPriority OperatingPriority { get; set; }
|
||||
public Int16 BatteriesCount { get; set; }
|
||||
public Int16 ClusterNumber { get; set; }
|
||||
public Int16 PvNumber { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
namespace InnovEnergy.App.SinexcelCommunication.DataTypes;
|
||||
|
||||
public enum OperatingPriority
|
||||
{
|
||||
ModeNotSynched = -1,
|
||||
LoadPriority = 0,
|
||||
BatteryPriority = 1,
|
||||
GridPriority = 2,
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
using InnovEnergy.App.SinexcelCommunication.DataTypes;
|
||||
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL;
|
||||
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
|
||||
using InnovEnergy.Lib.Units;
|
||||
using InnovEnergy.Lib.Units.Power;
|
||||
|
||||
namespace InnovEnergy.App.SinexcelCommunication.ESS;
|
||||
|
||||
public class InverterRecords
|
||||
{
|
||||
|
||||
public required Energy SelfGeneratedElectricity { get; init; }
|
||||
public required Energy ElectricityPurchased { get; init; }
|
||||
public required Energy ElectricityFed { get; init; }
|
||||
public required Energy BatteryChargeEnergy { get; init; }
|
||||
public required Energy BatteryDischargeEnergy { get; init; }
|
||||
public required Energy LoadPowerConsumption { get; init; }
|
||||
|
||||
public required Energy DailySelfGeneratedElectricity { get; init; }
|
||||
public required Energy DailyElectricityPurchased { get; init; }
|
||||
public required Energy DailyElectricityFed { get; init; }
|
||||
public required Energy BatteryDailyChargeEnergy { get; init; }
|
||||
public required Energy BatteryDailyDischargeEnergy { get; init; }
|
||||
public required Energy DailyLoadPowerConsumption { get; init; }
|
||||
|
||||
// public required ActivePower TotalConsumptionPower { get; init; }
|
||||
public required ActivePower TotalPhotovoltaicPower { get; init; }
|
||||
public required ActivePower TotalBatteryPower { get; init; }
|
||||
public required ActivePower TotalLoadPower { get; init; }
|
||||
public required ActivePower TotalGridPower { get; init; }
|
||||
|
||||
public required OperatingPriority OperatingPriority { get; init; }
|
||||
public required Voltage AvgBatteryVoltage { get; init; }
|
||||
public required Current TotalBatteryCurrent { get; init; }
|
||||
public required Percent AvgBatterySoc { get; init; }
|
||||
public required Percent AvgBatterySoh { get; init; }
|
||||
public required Temperature AvgBatteryTemp { get; init; }
|
||||
public required Percent MinSoc { get; init; }
|
||||
|
||||
public required Current MaxChargeCurrent { get; init; }
|
||||
public required Current MaxDischargingCurrent { get; init; }
|
||||
public required ActivePower GridPower { get; init; }
|
||||
public required Frequency GridFrequency { get; init; }
|
||||
public required ActivePower InverterPower { get; init; }
|
||||
public required EnablePowerLimitation EnableGridExport { get; init; }
|
||||
public required ActivePower GridExportPower { get; init; }
|
||||
|
||||
|
||||
public required IReadOnlyList<SinexcelRecord> Devices { get; init; }
|
||||
|
||||
public static InverterRecords? FromInverters(IReadOnlyList<SinexcelRecord>? records)
|
||||
{
|
||||
if (records is null || records.Count == 0)
|
||||
return null;
|
||||
|
||||
|
||||
return new InverterRecords
|
||||
{
|
||||
Devices = records,
|
||||
|
||||
DailySelfGeneratedElectricity = records.Sum(r => r.DailySelfGeneratedElectricity),
|
||||
DailyElectricityPurchased = records.Sum(r => r.DailyElectricityPurchased),
|
||||
DailyElectricityFed = records.Sum(r => r.DailyElectricityFed),
|
||||
BatteryDailyChargeEnergy = records.Sum(r => r.BatteryDailyChargeEnergy),
|
||||
BatteryDailyDischargeEnergy = records.Sum(r => r.BatteryDailyDischargeEnergy),
|
||||
DailyLoadPowerConsumption = records.Sum(r => r.DailyLoadPowerConsumption),
|
||||
|
||||
SelfGeneratedElectricity = records.Sum(r => r.SelfGeneratedElectricity),
|
||||
ElectricityPurchased = records.Sum(r => r.TotalEnergyToUser),
|
||||
ElectricityFed = records.Sum(r => r.TotalEnergyToGrid),
|
||||
BatteryChargeEnergy = records.Sum(r => r.BatteryCharge),
|
||||
BatteryDischargeEnergy = records.Sum(r => r.BatteryDischarge),
|
||||
LoadPowerConsumption = records.Sum(r => r.LoadPowerConsumption),
|
||||
|
||||
// TotalConsumptionPower = records.Sum(r => r.ConsumptionPower), // consumption same as load
|
||||
TotalPhotovoltaicPower = records.Sum(r => r.TotalPhotovoltaicPower),
|
||||
TotalBatteryPower = records.Sum(r => r.TotalBatteryPower),
|
||||
TotalLoadPower = records.Sum(r => r.TotalLoadPower),
|
||||
TotalGridPower = records.Sum(b => b.TotalGridPower),
|
||||
OperatingPriority = records.Select(r => r.WorkingMode).Distinct().Count() == 1 ? (OperatingPriority)records.First().WorkingMode: OperatingPriority.ModeNotSynched,
|
||||
AvgBatteryVoltage = records.SelectMany(r => new[] { r.Battery1Voltage.Value, r.Battery2Voltage.Value }).Where(v => v > 0).DefaultIfEmpty(0).Average(),
|
||||
TotalBatteryCurrent = records.SelectMany(r => new [] { r.Battery1Current.Value, r.Battery2Current.Value}).Sum(),
|
||||
AvgBatterySoc = records.SelectMany(r => new[] { r.Battery1Soc.Value, r.Battery2Soc.Value }).Average(),
|
||||
AvgBatterySoh = records.SelectMany(r => new[] { r.Battery1Soh.Value, r.Battery2Soh.Value }).Average(),
|
||||
MinSoc = records.SelectMany(r => new[] { r.Battery1BackupSoc, r.Battery2BackupSoc}).Min(),
|
||||
AvgBatteryTemp = records.SelectMany(r => new[] { r.Battery1Temperature.Value, r.Battery2Temperature.Value }).Average(),
|
||||
MaxChargeCurrent = records.SelectMany(r => new[] { r.Battery1MaxChargingCurrent, r.Battery2MaxChargingCurrent }).Min(),
|
||||
MaxDischargingCurrent = records.SelectMany(r => new[] { r.Battery1MaxDischargingCurrent, r.Battery2MaxDischargingCurrent }).Max(),
|
||||
GridPower = records.Sum(r => r.TotalGridPower),
|
||||
GridFrequency = records.Average(r => r.GridVoltageFrequency),
|
||||
InverterPower = records.Sum( r => r.InverterActivePower ),
|
||||
EnableGridExport = records.Select(r => r.EnableGridExport).Distinct().Count() == 1 ? records.First().EnableGridExport: EnablePowerLimitation.Prohibited,
|
||||
GridExportPower = records.Sum(r => r.PowerGridExportLimit)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace InnovEnergy.App.SinexcelCommunication.ESS;
|
|||
|
||||
public record StatusRecord
|
||||
{
|
||||
public required SinexcelRecord InverterRecord { get; set; }
|
||||
public required InverterRecords? InverterRecord { get; set; }
|
||||
|
||||
public required Config Config { get; set; }
|
||||
}
|
||||
|
|
@ -13,20 +13,37 @@ public static class MiddlewareAgent
|
|||
private static IPAddress? _controllerIpAddress;
|
||||
private static EndPoint? _endPoint;
|
||||
|
||||
public static void InitializeCommunicationToMiddleware()
|
||||
public static bool InitializeCommunicationToMiddleware()
|
||||
{
|
||||
_controllerIpAddress = FindVpnIp();
|
||||
if (Equals(IPAddress.None, _controllerIpAddress))
|
||||
try
|
||||
{
|
||||
Console.WriteLine("There is no VPN interface, exiting...");
|
||||
_controllerIpAddress = FindVpnIp();
|
||||
if (Equals(IPAddress.None, _controllerIpAddress))
|
||||
{
|
||||
Console.WriteLine("There is no VPN interface.");
|
||||
_udpListener = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const int udpPort = 9000;
|
||||
_endPoint = new IPEndPoint(_controllerIpAddress, udpPort);
|
||||
|
||||
_udpListener?.Close();
|
||||
_udpListener?.Dispose();
|
||||
|
||||
_udpListener = new UdpClient();
|
||||
_udpListener.Client.Blocking = false;
|
||||
_udpListener.Client.Bind(_endPoint);
|
||||
|
||||
Console.WriteLine($"UDP listener bound to {_endPoint}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to initialize middleware communication: {ex}");
|
||||
_udpListener = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const Int32 udpPort = 9000;
|
||||
_endPoint = new IPEndPoint(_controllerIpAddress, udpPort);
|
||||
|
||||
_udpListener = new UdpClient();
|
||||
_udpListener.Client.Blocking = false;
|
||||
_udpListener.Client.Bind(_endPoint);
|
||||
}
|
||||
|
||||
private static IPAddress FindVpnIp()
|
||||
|
|
@ -50,42 +67,96 @@ public static class MiddlewareAgent
|
|||
|
||||
return IPAddress.None;
|
||||
}
|
||||
|
||||
public static Configuration? SetConfigurationFile()
|
||||
{
|
||||
if (_udpListener.Available > 0)
|
||||
try
|
||||
{
|
||||
// Ensure listener is initialized
|
||||
if (_udpListener == null)
|
||||
{
|
||||
Console.WriteLine("UDP listener not initialized, trying to initialize...");
|
||||
InitializeCommunicationToMiddleware();
|
||||
|
||||
if (_udpListener == null)
|
||||
{
|
||||
Console.WriteLine("Failed to initialize UDP listener.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if data is available
|
||||
if (_udpListener.Available <= 0)
|
||||
return null;
|
||||
|
||||
IPEndPoint? serverEndpoint = null;
|
||||
|
||||
var replyMessage = "ACK";
|
||||
var replyData = Encoding.UTF8.GetBytes(replyMessage);
|
||||
|
||||
|
||||
var udpMessage = _udpListener.Receive(ref serverEndpoint);
|
||||
var message = Encoding.UTF8.GetString(udpMessage);
|
||||
|
||||
var message = Encoding.UTF8.GetString(udpMessage);
|
||||
|
||||
Console.WriteLine($"Received raw UDP message from {serverEndpoint}: {message}");
|
||||
|
||||
var config = JsonSerializer.Deserialize<Configuration>(message);
|
||||
|
||||
|
||||
if (config != null)
|
||||
{
|
||||
Console.WriteLine($"Received a configuration message: " +
|
||||
"MinimumSoC is " + config.MinimumSoC + " and operating priorty is " +config.OperatingPriority + "Number of batteries is " + config.BatteriesCount
|
||||
+ "Maximum Charging current is "+ config.MaximumChargingCurrent + "/n" + "Maximum Discharging current is " + config.MaximumDischargingCurrent
|
||||
+ "StartTimeChargeandDischargeDayandTime is" + config.StartTimeChargeandDischargeDayandTime + "StopTimeChargeandDischargeDayandTime is" + config.StopTimeChargeandDischargeDayandTime
|
||||
+ "TimeChargeandDischargePowert is " + config.TimeChargeandDischargePower + " Control permission is" + config.ControlPermission);
|
||||
|
||||
// Send the reply to the sender's endpoint
|
||||
Console.WriteLine(
|
||||
$"Received a configuration message:\n" +
|
||||
$"MinimumSoC: {config.MinimumSoC}\n" +
|
||||
$"OperatingPriority: {config.OperatingPriority}\n" +
|
||||
$"Number of batteries: {config.BatteriesCount}\n" +
|
||||
$"Maximum Charging current: {config.MaximumChargingCurrent}\n" +
|
||||
$"Maximum Discharging current: {config.MaximumDischargingCurrent}\n" +
|
||||
$"StartTimeChargeandDischargeDayandTime: {config.StartTimeChargeandDischargeDayandTime}\n" +
|
||||
$"StopTimeChargeandDischargeDayandTime: {config.StopTimeChargeandDischargeDayandTime}\n" +
|
||||
$"TimeChargeandDischargePower: {config.TimeChargeandDischargePower}\n" +
|
||||
$"ControlPermission: {config.ControlPermission}"
|
||||
);
|
||||
|
||||
// Send ACK
|
||||
var replyMessage = "ACK";
|
||||
var replyData = Encoding.UTF8.GetBytes(replyMessage);
|
||||
|
||||
_udpListener.Send(replyData, replyData.Length, serverEndpoint);
|
||||
Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}");
|
||||
|
||||
return config;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Received UDP message but failed to deserialize Configuration.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (_endPoint != null && !_endPoint.Equals(_udpListener.Client.LocalEndPoint as IPEndPoint))
|
||||
catch (SocketException ex)
|
||||
{
|
||||
Console.WriteLine("UDP address has changed, rebinding...");
|
||||
Console.WriteLine($"Socket error in SetConfigurationFile: {ex}");
|
||||
|
||||
// Recover by reinitializing
|
||||
try
|
||||
{
|
||||
_udpListener?.Close();
|
||||
_udpListener?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
_udpListener = null;
|
||||
InitializeCommunicationToMiddleware();
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.WriteLine($"JSON deserialization error: {ex}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Unexpected error in SetConfigurationFile: {ex}");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.IO.Compression;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
|
@ -24,6 +25,7 @@ using Formatting = Newtonsoft.Json.Formatting;
|
|||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
using static InnovEnergy.App.SinexcelCommunication.MiddlewareClasses.MiddlewareAgent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using InnovEnergy.App.SinexcelCommunication.AggregationService;
|
||||
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
|
||||
using InnovEnergy.Lib.Protocols.Modbus.Protocol;
|
||||
using static InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType.WorkingMode;
|
||||
|
|
@ -35,12 +37,12 @@ namespace InnovEnergy.App.SinexcelCommunication;
|
|||
[SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")]
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(5);
|
||||
private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file
|
||||
private static UInt16 _fileCounter = 0;
|
||||
private static Channel _sinexcelChannel1;
|
||||
private static Channel _sinexcelChannel2;
|
||||
|
||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(10);
|
||||
private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file
|
||||
private static UInt16 _fileCounter = 0;
|
||||
private static List<Channel> _sinexcelChannel;
|
||||
private static DateTime? _lastUploadedAggregatedDate;
|
||||
private static DailyEnergyData? _pendingDailyData;
|
||||
private static readonly String SwVersionNumber = " V1.00." + DateTime.Today;
|
||||
private const String VpnServerIp = "10.2.0.11";
|
||||
private static Boolean _subscribedToQueue = false;
|
||||
|
|
@ -57,19 +59,29 @@ internal static class Program
|
|||
private const Int32 HeartbeatIntervalSeconds = 60;
|
||||
|
||||
|
||||
// move all this to config file
|
||||
private const String Port1 = "/dev/ttyUSB0";
|
||||
private const String Port2 = "/dev/ttyUSB1";
|
||||
|
||||
private const Byte SlaveId = 1;
|
||||
private const Parity Parity = 0; //none
|
||||
private const Int32 StopBits = 1;
|
||||
private const Int32 BaudRate = 115200;
|
||||
private const Int32 DataBits = 8;
|
||||
|
||||
|
||||
public static async Task Main(String[] args)
|
||||
{
|
||||
_sinexcelChannel1 = new SerialPortChannel(Port1, BaudRate, Parity, DataBits, StopBits);
|
||||
_sinexcelChannel2 = new SerialPortChannel(Port2, BaudRate, Parity, DataBits, StopBits);
|
||||
var config = Config.Load();
|
||||
var d = config.Devices;
|
||||
var serial = d.Serial;
|
||||
|
||||
|
||||
Channel CreateChannel(SodiDevice device) => device.DeviceState == DeviceState.Disabled
|
||||
? new NullChannel()
|
||||
: new SerialPortChannel(device.Port,serial.BaudRate,serial.Parity,serial.DataBits,serial.StopBits);
|
||||
|
||||
_sinexcelChannel = new List<Channel>()
|
||||
{
|
||||
CreateChannel(d.Inverter1),
|
||||
CreateChannel(d.Inverter2),
|
||||
CreateChannel(d.Inverter3),
|
||||
CreateChannel(d.Inverter4)
|
||||
};
|
||||
|
||||
|
||||
InitializeCommunicationToMiddleware();
|
||||
while (true)
|
||||
|
|
@ -91,21 +103,24 @@ internal static class Program
|
|||
Watchdog.NotifyReady();
|
||||
|
||||
Console.WriteLine("Starting Sinexcel Communication, SW Version : " + SwVersionNumber);
|
||||
|
||||
var sinexcelDevice1 = new SinexcelDevice(_sinexcelChannel1, SlaveId);
|
||||
var sinexcelDevice2 = new SinexcelDevice(_sinexcelChannel2, SlaveId);
|
||||
|
||||
var devices = _sinexcelChannel
|
||||
.Where(ch => ch is not NullChannel)
|
||||
.Select(ch => new SinexcelDevice(ch, SlaveId))
|
||||
.ToList();
|
||||
|
||||
StatusRecord? ReadStatus()
|
||||
{
|
||||
var config = Config.Load();
|
||||
var sinexcelRecord1 = sinexcelDevice1.Read();
|
||||
var sinexcelRecord2 = sinexcelDevice2.Read();
|
||||
var config = Config.Load();
|
||||
var listOfInverterRecord = devices
|
||||
.Select(device => device.Read())
|
||||
.ToList();
|
||||
|
||||
InverterRecords? inverterRecords = InverterRecords.FromInverters(listOfInverterRecord);
|
||||
|
||||
return new StatusRecord
|
||||
{
|
||||
InverterRecord1 = sinexcelRecord1,
|
||||
InverterRecord2 = sinexcelRecord2,
|
||||
InverterRecord = inverterRecords,
|
||||
Config = config // load from disk every iteration, so config can be changed while running
|
||||
};
|
||||
}
|
||||
|
|
@ -128,103 +143,46 @@ internal static class Program
|
|||
try
|
||||
{
|
||||
Watchdog.NotifyAlive();
|
||||
|
||||
var startTime = DateTime.Now;
|
||||
Console.WriteLine("***************************** Reading Battery Data *********************************************");
|
||||
Console.WriteLine(startTime.ToString("HH:mm:ss.fff ")+ "Start Reading");
|
||||
|
||||
// the order matter of the next three lines
|
||||
var statusrecord = ReadStatus();
|
||||
if (statusrecord == null)
|
||||
return null;
|
||||
_ = CreateAggregatedData(statusrecord);
|
||||
|
||||
Console.WriteLine(" ************************************************ Inverter 1 ************************************************ ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.SystemDateTime + " SystemDateTime ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.TotalPhotovoltaicPower + " TotalPhotovoltaicPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.TotalBatteryPower + " TotalBatteryPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.TotalLoadPower + " TotalLoadPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.TotalGridPower + " TotalGridPower ");
|
||||
var invDevices = statusrecord.InverterRecord?.Devices;
|
||||
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery1Power + " Battery1Power ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery1Soc + " Battery1Soc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery1BackupSoc + " Battery1BackupSoc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery1MinSoc + " Battery1MinSoc ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery2Power + " Battery2Power ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery2Soc + " Battery2Soc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery2BackupSoc + " Battery2BackupSoc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.Battery2MinSoc + " Battery2MinSoc ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.EnableGridExport + " EnableGridExport ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.PowerGridExportLimit + " PowerGridExportLimit ");
|
||||
if (invDevices != null)
|
||||
{
|
||||
var index = 1;
|
||||
foreach (var inverter in invDevices)
|
||||
PrintInverterData(inverter, index++);
|
||||
}
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.PowerOn + " PowerOn ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.PowerOff + " PowerOff ");
|
||||
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.WorkingMode + " WorkingMode ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.GridSwitchMethod + " GridSwitchMethod ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.ThreePhaseWireSystem + " ThreePhaseWireSystem ");
|
||||
|
||||
Console.WriteLine(" ************************************************ Inverter 2 ************************************************ ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord2.SystemDateTime + " SystemDateTime ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.TotalPhotovoltaicPower + " TotalPhotovoltaicPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.TotalBatteryPower + " TotalBatteryPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.TotalLoadPower + " TotalLoadPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.TotalGridPower + " TotalGridPower ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery1Power + " Battery1Power ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery1Soc + " Battery1Soc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery1BackupSoc + " Battery1BackupSoc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery1MinSoc + " Battery1MinSoc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery2Power + " Battery2Power ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery2Soc + " Battery2Soc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery2BackupSoc + " Battery2BackupSoc ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.Battery2MinSoc + " Battery2MinSoc ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord2.EnableGridExport + " EnableGridExport ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.PowerGridExportLimit + " PowerGridExportLimit ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.PowerOn + " PowerOn ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.PowerOff + " PowerOff ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.WorkingMode + " WorkingMode ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.GridSwitchMethod + " GridSwitchMethod ");
|
||||
Console.WriteLine( statusrecord.InverterRecord2.ThreePhaseWireSystem + " ThreePhaseWireSystem ");
|
||||
/*
|
||||
Console.WriteLine( statusrecord.InverterRecord1.RepetitiveWeeks + " RepetitiveWeeks ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.EffectiveStartDate + " EffectiveStartDate ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.EffectiveEndDate + " EffectiveEndDate ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.ChargingPowerPeriod1 + " ChargingPowerPeriod1 ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.DishargingPowerPeriod1 + " dischargingPowerPeriod1 ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.ChargeStartTimePeriod1 + " ChargeStartTimePeriod1 ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.ChargeEndTimePeriod1 + " ChargeEndTimePeriod1 ");
|
||||
|
||||
Console.WriteLine( statusrecord.InverterRecord1.DischargeStartTimePeriod1 + " DischargeStartTimePeriod1 ");
|
||||
Console.WriteLine( statusrecord.InverterRecord1.DischargeEndTimePeriod1 + " DischargeEndTimePeriod1 ");*/
|
||||
|
||||
SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord);
|
||||
statusrecord.ControlConstants();
|
||||
SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord);
|
||||
statusrecord.ControlConstants();
|
||||
|
||||
Console.WriteLine( " ************************************ We are writing ************************************");
|
||||
var startWritingTime = DateTime.Now;
|
||||
Console.WriteLine(startWritingTime.ToString("HH:mm:ss.fff ") +"start Writing");
|
||||
statusrecord?.Config.Save(); // save the config file
|
||||
|
||||
if (statusrecord is { Config.ControlPermission: true })
|
||||
if (statusrecord is { Config.ControlPermission: true, InverterRecord.Devices: not null })
|
||||
{
|
||||
Console.WriteLine(" We have the Right to Write");
|
||||
sinexcelDevice1.Write(statusrecord.InverterRecord1);
|
||||
sinexcelDevice2.Write(statusrecord.InverterRecord2);
|
||||
Console.WriteLine("We have the Right to Write");
|
||||
|
||||
foreach (var pair in devices.Zip(statusrecord.InverterRecord.Devices))
|
||||
pair.First.Write(pair.Second);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" Nooooooo We cant' have the Right to Write");
|
||||
Console.WriteLine("Nooooooo We can't have the Right to Write");
|
||||
}
|
||||
|
||||
var stop = DateTime.Now;
|
||||
Console.WriteLine("***************************** Writing finished *********************************************");
|
||||
Console.WriteLine(stop.ToString("HH:mm:ss.fff ")+ "Cycle end");
|
||||
return statusrecord;
|
||||
}
|
||||
catch (CrcException e)
|
||||
|
|
@ -239,60 +197,216 @@ internal static class Program
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this is synchronous because :
|
||||
//
|
||||
// it only appends one line once per hour and once per day
|
||||
//
|
||||
// it should not meaningfully affect a 10-second loop
|
||||
|
||||
private static async Task CreateAggregatedData(StatusRecord statusRecord)
|
||||
{
|
||||
|
||||
DateTime now = DateTime.Now;
|
||||
string baseFolder = AppContext.BaseDirectory;
|
||||
|
||||
// 1) Finalize previous hour if hour changed
|
||||
var hourlyData = EnergyAggregation.ProcessHourlyData(statusRecord, now);
|
||||
/*if (hourlyData != null)
|
||||
{
|
||||
AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder);
|
||||
}*/
|
||||
if (hourlyData != null)
|
||||
{
|
||||
AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder);
|
||||
|
||||
if (_pendingDailyData != null && hourlyData.Timestamp.Hour == 23)
|
||||
{
|
||||
AggregatedDataFileWriter.AppendDailyData(_pendingDailyData, baseFolder);
|
||||
_pendingDailyData = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Save daily line near end of day
|
||||
var dailyData = EnergyAggregation.TryCreateDailyData(statusRecord, now);
|
||||
if (dailyData != null)
|
||||
{
|
||||
_pendingDailyData = dailyData;
|
||||
//AggregatedDataFileWriter.AppendDailyData(dailyData, baseFolder);
|
||||
}
|
||||
|
||||
// 3) After midnight, upload yesterday's completed file once
|
||||
var yesterday = now.Date.AddDays(-1);
|
||||
|
||||
if (now.Hour == 0 && now.Minute == 0)
|
||||
{
|
||||
if (_lastUploadedAggregatedDate != yesterday)
|
||||
{
|
||||
Console.WriteLine(" We are inside the lastuploaded Aggregate");
|
||||
string filePath = Path.Combine(
|
||||
baseFolder,
|
||||
"AggregatedData",
|
||||
yesterday.ToString("ddMMyyyy") + ".json");
|
||||
|
||||
bool uploaded = await PushAggregatedFileToS3(filePath, statusRecord);
|
||||
|
||||
if (uploaded)
|
||||
{
|
||||
_lastUploadedAggregatedDate = yesterday;
|
||||
Console.WriteLine($"Uploaded aggregated file for {yesterday:ddMMyyyy}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Uploaded failed for {yesterday:ddMMyyyy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Boolean> PushAggregatedFileToS3( String localFilePath, StatusRecord statusRecord)
|
||||
{
|
||||
var s3Config = statusRecord.Config.S3;
|
||||
if (s3Config is null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(localFilePath))
|
||||
{
|
||||
Console.WriteLine($"File not found: {localFilePath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var jsonString = await File.ReadAllTextAsync(localFilePath);
|
||||
|
||||
// Example S3 object name: 09032026.json
|
||||
var s3Path = Path.GetFileName(localFilePath);
|
||||
var request = s3Config.CreatePutRequest(s3Path);
|
||||
var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(jsonString));
|
||||
|
||||
using var content = new StringContent(base64String, Encoding.UTF8, "application/base64");
|
||||
|
||||
Console.WriteLine("Sending Content-Type: application/base64; charset=utf-8");
|
||||
Console.WriteLine($"S3 Path: {s3Path}");
|
||||
|
||||
var response = await request.PutAsync(content);
|
||||
|
||||
if (response.StatusCode != 200)
|
||||
{
|
||||
Console.WriteLine("ERROR: PUT");
|
||||
var error = await response.GetStringAsync();
|
||||
Console.WriteLine(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Uploaded successfully: {s3Path}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"PushAggregatedFileToS3 failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ControlConstants(this StatusRecord? statusrecord)
|
||||
{
|
||||
if (statusrecord == null) return;
|
||||
if (statusrecord?.InverterRecord?.Devices == null) return;
|
||||
|
||||
// Compute once (same for all inverters)
|
||||
var config = statusrecord.Config;
|
||||
|
||||
var isChargePeriod = IsNowInsideDateAndTime(
|
||||
config.StartTimeChargeandDischargeDayandTime,
|
||||
config.StopTimeChargeandDischargeDayandTime
|
||||
);
|
||||
|
||||
statusrecord.InverterRecord1.Battery1BackupSoc = (Single)statusrecord.Config.MinSoc ;
|
||||
statusrecord.InverterRecord1.Battery2BackupSoc = (Single)statusrecord.Config.MinSoc ;
|
||||
statusrecord.InverterRecord1.RepetitiveWeeks = SinexcelWeekDays.All;
|
||||
|
||||
|
||||
var isChargePeriod = IsNowInsideDateAndTime(statusrecord.Config.StartTimeChargeandDischargeDayandTime, statusrecord.Config.StopTimeChargeandDischargeDayandTime);
|
||||
|
||||
|
||||
Console.WriteLine("Are we inside the charge/Discharge time " + isChargePeriod);
|
||||
|
||||
if (statusrecord.Config.OperatingPriority != TimeChargeDischarge)
|
||||
foreach (var inverter in statusrecord.InverterRecord.Devices)
|
||||
{
|
||||
statusrecord.InverterRecord1.WorkingMode = statusrecord.Config.OperatingPriority;
|
||||
}
|
||||
else if (statusrecord.Config.OperatingPriority == TimeChargeDischarge && isChargePeriod)
|
||||
{
|
||||
statusrecord.InverterRecord1.WorkingMode = statusrecord.Config.OperatingPriority;
|
||||
// constants for every inverter
|
||||
inverter.Battery1BackupSoc = (float)config.MinSoc;
|
||||
inverter.Battery2BackupSoc = (float)config.MinSoc;
|
||||
inverter.RepetitiveWeeks = SinexcelWeekDays.All;
|
||||
|
||||
if (statusrecord.Config.TimeChargeandDischargePower > 0)
|
||||
var operatingMode = config.OperatingPriority switch
|
||||
{
|
||||
statusrecord.InverterRecord1.EffectiveStartDate = statusrecord.Config.StartTimeChargeandDischargeDayandTime.Date;
|
||||
statusrecord.InverterRecord1.EffectiveEndDate = statusrecord.Config.StopTimeChargeandDischargeDayandTime.Date;
|
||||
statusrecord.InverterRecord1.ChargingPowerPeriod1 = Math.Abs(statusrecord.Config.TimeChargeandDischargePower);
|
||||
statusrecord.InverterRecord1.ChargeStartTimePeriod1 = statusrecord.Config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
statusrecord.InverterRecord1.ChargeEndTimePeriod1 = statusrecord.Config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
|
||||
statusrecord.InverterRecord1.DischargeStartTimePeriod1 = TimeSpan.Zero;
|
||||
statusrecord.InverterRecord1.DischargeEndTimePeriod1 = TimeSpan.Zero;
|
||||
OperatingPriority.LoadPriority => SpontaneousSelfUse,
|
||||
OperatingPriority.BatteryPriority => TimeChargeDischarge,
|
||||
OperatingPriority.GridPriority => PrioritySellElectricity,
|
||||
_ => SpontaneousSelfUse
|
||||
};
|
||||
|
||||
if (operatingMode!= TimeChargeDischarge)
|
||||
{
|
||||
inverter.WorkingMode = operatingMode;
|
||||
}
|
||||
else if (isChargePeriod)
|
||||
{
|
||||
inverter.WorkingMode = operatingMode;
|
||||
inverter.EffectiveStartDate = config.StartTimeChargeandDischargeDayandTime.Date;
|
||||
inverter.EffectiveEndDate = config.StopTimeChargeandDischargeDayandTime.Date;
|
||||
|
||||
var power = config.TimeChargeandDischargePower;
|
||||
|
||||
if (power > 0)
|
||||
{
|
||||
inverter.ChargingPowerPeriod1 = Math.Abs(power);
|
||||
inverter.ChargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
inverter.ChargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
|
||||
inverter.DischargeStartTimePeriod1 = TimeSpan.Zero;
|
||||
inverter.DischargeEndTimePeriod1 = TimeSpan.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
inverter.DishargingPowerPeriod1 = Math.Abs(power);
|
||||
inverter.DischargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
inverter.DischargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
|
||||
inverter.ChargeStartTimePeriod1 = TimeSpan.Zero;
|
||||
inverter.ChargeEndTimePeriod1 = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
statusrecord.InverterRecord1.EffectiveStartDate = statusrecord.Config.StartTimeChargeandDischargeDayandTime.Date;
|
||||
statusrecord.InverterRecord1.EffectiveEndDate = statusrecord.Config.StopTimeChargeandDischargeDayandTime.Date;
|
||||
statusrecord.InverterRecord1.DishargingPowerPeriod1 = Math.Abs(statusrecord.Config.TimeChargeandDischargePower);
|
||||
statusrecord.InverterRecord1.DischargeStartTimePeriod1 = statusrecord.Config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
statusrecord.InverterRecord1.DischargeEndTimePeriod1 = statusrecord.Config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
|
||||
|
||||
statusrecord.InverterRecord1.ChargeStartTimePeriod1 = TimeSpan.Zero;
|
||||
statusrecord.InverterRecord1.ChargeEndTimePeriod1 = TimeSpan.Zero;
|
||||
inverter.WorkingMode = SpontaneousSelfUse;
|
||||
}
|
||||
|
||||
|
||||
inverter.PowerOn = 1;
|
||||
inverter.PowerOff = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
statusrecord.InverterRecord1.WorkingMode = SpontaneousSelfUse;
|
||||
}
|
||||
statusrecord.InverterRecord1.PowerOn = 1;
|
||||
statusrecord.InverterRecord1.PowerOff = 0;
|
||||
//statusrecord.InverterRecord.FaultClearing = 1;
|
||||
}
|
||||
|
||||
static void PrintInverterData(SinexcelRecord r, int index)
|
||||
{
|
||||
Console.WriteLine($" ************************************************ Inverter {index} ************************************************ ");
|
||||
|
||||
//Console.WriteLine($"{r.SystemDateTime} SystemDateTime");
|
||||
Console.WriteLine($"{r.TotalPhotovoltaicPower} TotalPhotovoltaicPower");
|
||||
Console.WriteLine($"{r.TotalBatteryPower} TotalBatteryPower");
|
||||
Console.WriteLine($"{r.TotalLoadPower} TotalLoadPower");
|
||||
Console.WriteLine($"{r.TotalGridPower} TotalGridPower");
|
||||
|
||||
Console.WriteLine($"{r.Battery1Power} Battery1Power");
|
||||
Console.WriteLine($"{r.Battery1Soc} Battery1Soc");
|
||||
Console.WriteLine($"{r.Battery1BackupSoc} Battery1BackupSoc");
|
||||
Console.WriteLine($"{r.Battery1MinSoc} Battery1MinSoc");
|
||||
|
||||
Console.WriteLine($"{r.Battery2Power} Battery2Power");
|
||||
Console.WriteLine($"{r.Battery2Soc} Battery2Soc");
|
||||
Console.WriteLine($"{r.Battery2BackupSoc} Battery2BackupSoc");
|
||||
Console.WriteLine($"{r.Battery2MinSoc} Battery2MinSoc");
|
||||
|
||||
Console.WriteLine($"{r.EnableGridExport} EnableGridExport");
|
||||
Console.WriteLine($"{r.PowerGridExportLimit} PowerGridExportLimit");
|
||||
|
||||
Console.WriteLine($"{r.PowerOn} PowerOn");
|
||||
Console.WriteLine($"{r.PowerOff} PowerOff");
|
||||
Console.WriteLine($"{r.WorkingMode} WorkingMode");
|
||||
Console.WriteLine($"{r.GridSwitchMethod} GridSwitchMethod");
|
||||
Console.WriteLine($"{r.ThreePhaseWireSystem} ThreePhaseWireSystem");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static bool IsNowInsideDateAndTime(DateTime effectiveStart, DateTime effectiveEnd)
|
||||
|
|
@ -309,7 +423,7 @@ internal static class Program
|
|||
|
||||
private static StatusMessage GetSodiHomeStateAlarm(StatusRecord? record)
|
||||
{
|
||||
var s3Bucket = Config.Load().S3?.Bucket;
|
||||
var s3Bucket = record?.Config.S3?.Bucket; // this should not load the config file, only use the one from status record TO change this in other project
|
||||
|
||||
var alarmList = new List<AlarmOrWarning>();
|
||||
var warningList = new List<AlarmOrWarning>();
|
||||
|
|
@ -424,13 +538,7 @@ internal static class Program
|
|||
var stateChanged = currentSalimaxState.Status != _prevSodiohomeAlarmState;
|
||||
var contentChanged = HasErrorsOrWarningsChanged(currentSalimaxState);
|
||||
var needsHeartbeat = (DateTime.Now - _lastHeartbeatTime).TotalSeconds >= HeartbeatIntervalSeconds;
|
||||
Console.WriteLine($"subscribedNow={subscribedNow}");
|
||||
Console.WriteLine($"_subscribedToQueue={_subscribedToQueue}");
|
||||
Console.WriteLine($"stateChanged={stateChanged}");
|
||||
Console.WriteLine($"contentChanged={contentChanged}");
|
||||
Console.WriteLine($"needsHeartbeat={needsHeartbeat}");
|
||||
Console.WriteLine($"s3Bucket null? {s3Bucket == null}");
|
||||
|
||||
|
||||
if (s3Bucket == null)
|
||||
{
|
||||
Console.WriteLine("⚠ S3 bucket not configured. Skipping middleware send.");
|
||||
|
|
@ -526,47 +634,48 @@ internal static class Program
|
|||
|
||||
}
|
||||
|
||||
private static async Task<Boolean> SaveModbusTcpFile(StatusRecord status)
|
||||
private static async Task<Boolean> SaveModbusTcpFile(StatusRecord status)
|
||||
{
|
||||
var modbusData = new Dictionary<String, UInt16>();
|
||||
|
||||
// SYSTEM DATA
|
||||
var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version
|
||||
var result2 = ConvertToModbusRegisters(status.InverterRecord1.SystemDateTime.ToUnixTime(), "UInt32", 30002);
|
||||
// SYSTEM DATA
|
||||
var result3 = ConvertToModbusRegisters(status.InverterRecord1.WorkingMode, "UInt16", 30004);
|
||||
|
||||
// BATTERY SUMMARY (assuming single battery [0]) // this to be improved
|
||||
try
|
||||
{
|
||||
// SYSTEM DATA
|
||||
var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version
|
||||
var result2 = ConvertToModbusRegisters(DateTimeOffset.Now.ToUnixTimeSeconds(), "UInt32", 30002);
|
||||
// SYSTEM DATA
|
||||
var result3 = ConvertToModbusRegisters(status.InverterRecord.OperatingPriority, "UInt16", 30004);
|
||||
|
||||
|
||||
var result4 = ConvertToModbusRegisters((status.Config.BatteriesCount), "UInt16", 31000);
|
||||
var result8 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Voltage.Value * 10), "UInt16", 31001);
|
||||
var result12 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Voltage.Value * 10), "Int16", 31002);
|
||||
var result13 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Current.Value * 10), "Int32", 31003);
|
||||
var result16 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Current.Value * 10), "Int32", 31005);
|
||||
var result9 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Soc.Value * 100), "UInt16", 31007);
|
||||
var result14 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Soc.Value * 100), "UInt16", 31008);
|
||||
var result5 = ConvertToModbusRegisters((status.InverterRecord1.TotalBatteryPower.Value * 10), "Int32", 31009);
|
||||
var result8 = ConvertToModbusRegisters((0), "UInt16", 31001); // this is ignored as dosen't exist in Sinexcel
|
||||
var result12 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryVoltage.Value * 10), "Int16", 31002);
|
||||
var result13 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryCurrent.Value * 10), "Int32", 31003);
|
||||
var result16 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoc.Value * 100), "UInt16", 31005);
|
||||
var result9 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryPower.Value * 10), "Int32", 31006);
|
||||
var result14 = ConvertToModbusRegisters((status.InverterRecord.MinSoc.Value * 100), "UInt16", 31008);
|
||||
var result55 = ConvertToModbusRegisters(100 * 100, "UInt16", 31009); //this is ignored as dosen't exist in Sinexcel
|
||||
var result5 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoh.Value * 100), "UInt16", 31009);
|
||||
|
||||
var result7 = ConvertToModbusRegisters((status.InverterRecord1.Battery1BackupSoc * 100), "UInt16", 31011);
|
||||
var result20 = ConvertToModbusRegisters((status.InverterRecord1.Battery2BackupSoc * 100), "UInt16", 31012);
|
||||
var result15 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Soh.Value * 100), "UInt16", 31013);
|
||||
var result26 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Soh.Value * 100), "UInt16", 31014);
|
||||
var result21 = ConvertToModbusRegisters((status.InverterRecord1.Battery1MaxChargingCurrent * 10), "UInt16", 31016);
|
||||
var result22 = ConvertToModbusRegisters((status.InverterRecord1.Battery1MaxDischargingCurrent * 10), "UInt16", 31017);
|
||||
|
||||
var result7 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryTemp.Value * 100), "Int16", 31011);
|
||||
var result20 = ConvertToModbusRegisters((status.InverterRecord.MaxChargeCurrent.Value * 10), "UInt16", 31012);
|
||||
var result15 = ConvertToModbusRegisters((status.InverterRecord.MaxDischargingCurrent.Value * 10), "UInt16", 31013);
|
||||
var result26 = ConvertToModbusRegisters(60 * 10, "UInt16", 31014); //this is ignored as dosen't exist in Sinexcel
|
||||
|
||||
var result18 = ConvertToModbusRegisters((status.InverterRecord1.PvTotalPower * 10), "UInt32", 32000);
|
||||
var result19 = ConvertToModbusRegisters((status.InverterRecord1.GridPower * 10), "Int32", 33000);
|
||||
var result23 = ConvertToModbusRegisters((status.InverterRecord1.GridVoltageFrequency * 10), "UInt16", 33002);
|
||||
var result18 = ConvertToModbusRegisters((status.InverterRecord.TotalPhotovoltaicPower.Value * 10), "UInt32", 32000);
|
||||
var result19 = ConvertToModbusRegisters((status.InverterRecord.TotalGridPower.Value * 10), "Int32", 33000);
|
||||
var result23 = ConvertToModbusRegisters((status.InverterRecord.GridFrequency.Value * 10), "UInt16", 33002);
|
||||
|
||||
var result24 = ConvertToModbusRegisters((status.InverterRecord1.WorkingMode), "UInt16", 34000);
|
||||
var result25 = ConvertToModbusRegisters((status.InverterRecord1.InverterActivePower * 10), "Int32", 34001);
|
||||
var result29 = ConvertToModbusRegisters((status.InverterRecord1.EnableGridExport ), "UInt16", 34003);
|
||||
var result27 = ConvertToModbusRegisters((status.InverterRecord1.PowerGridExportLimit ), "Int16", 34004);
|
||||
|
||||
var result24 = ConvertToModbusRegisters((status.InverterRecord.OperatingPriority), "UInt16", 34000);
|
||||
var result25 = ConvertToModbusRegisters((status.InverterRecord.InverterPower.Value * 10), "Int32", 34001);
|
||||
var result29 = ConvertToModbusRegisters((status.InverterRecord.EnableGridExport ), "UInt16", 35002);
|
||||
var result27 = ConvertToModbusRegisters((status.InverterRecord.GridExportPower.Value ), "Int16", 35003);
|
||||
// Merge all results into one dictionary
|
||||
var allResults = new[]
|
||||
{
|
||||
result1, result2, result3, result4, result5, result23, result24, result25, result29, result27, result26, result7, result8, result9, result16, result20, result12, result13, result14, result15, result18, result19, result21, result22
|
||||
result1, result2, result3, result4, result5, result23, result24, result25, result29, result27, result26, result7, result8, result9, result16, result20, result12, result13, result14, result15, result18, result19
|
||||
, result55
|
||||
};
|
||||
|
||||
foreach (var result in allResults)
|
||||
|
|
@ -580,11 +689,16 @@ internal static class Program
|
|||
var json = JsonSerializer.Serialize(modbusData, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync("/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json", json);
|
||||
|
||||
//Console.WriteLine("JSON file written successfully.");
|
||||
//Console.WriteLine(json);
|
||||
var stopTime = DateTime.Now;
|
||||
Console.WriteLine(stopTime.ToString("HH:mm:ss.fff" )+ " Finish the loop");
|
||||
// Console.WriteLine("JSON file written successfully.");
|
||||
// Console.WriteLine(json);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static Dictionary<string, ushort> ConvertToModbusRegisters(object value, string outputType, int startingAddress)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using InnovEnergy.App.SinexcelCommunication.DataTypes;
|
||||
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
|
||||
using InnovEnergy.Lib.Utils;
|
||||
using static System.Text.Json.JsonSerializer;
|
||||
|
|
@ -10,17 +11,21 @@ namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
|
|||
[SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")]
|
||||
public class Config
|
||||
{
|
||||
|
||||
private static String DefaultConfigFilePath => Path.Combine(Environment.CurrentDirectory, "config.json");
|
||||
private static DateTime DefaultDatetime => new(2025, 01, 01, 09, 00, 00);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public required DeviceConfig Devices { get; set; }
|
||||
//public required Boolean DynamicPricingEnabled { get; set; }
|
||||
//public required DynamicPricingMode DynamicPricingMode { get; set; }
|
||||
//public required Decimal CheapPrice { get; set; }
|
||||
//public required Decimal HighPrice { get; set; }
|
||||
public required Double MinSoc { get; set; }
|
||||
public required Double GridSetPoint { get; set; }
|
||||
public required Double MaximumDischargingCurrent { get; set; }
|
||||
public required Double MaximumChargingCurrent { get; set; }
|
||||
public required WorkingMode OperatingPriority { get; set; }
|
||||
public required OperatingPriority OperatingPriority { get; set; }
|
||||
public required Int16 BatteriesCount { get; set; }
|
||||
public required Int16 ClusterNumber { get; set; }
|
||||
public required Int16 PvNumber { get; set; }
|
||||
|
|
@ -34,15 +39,26 @@ public class Config
|
|||
|
||||
public required S3Config? S3 { get; set; }
|
||||
|
||||
private static String? LastSavedData { get; set; }
|
||||
private static String? LastSavedData { get; set; }
|
||||
private static DateTime? LoadedWriteTimeUtc { get; set; }
|
||||
|
||||
public static Config Default => new()
|
||||
{
|
||||
Devices = new ()
|
||||
{
|
||||
Serial = new() {BaudRate = 115200, Parity = 0, StopBits = 1, DataBits = 8},
|
||||
Inverter1 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB0", SlaveId = 1},
|
||||
Inverter2 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB1", SlaveId = 1},
|
||||
Inverter3 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB3", SlaveId = 1},
|
||||
Inverter4 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB4", SlaveId = 1},
|
||||
},
|
||||
//DynamicPricingEnabled = false,
|
||||
//DynamicPricingMode = DynamicPricingMode.Disabled,
|
||||
MinSoc = 20,
|
||||
GridSetPoint = 0,
|
||||
MaximumChargingCurrent = 180,
|
||||
MaximumDischargingCurrent = 180,
|
||||
OperatingPriority = WorkingMode.TimeChargeDischarge,
|
||||
OperatingPriority = OperatingPriority.LoadPriority,
|
||||
BatteriesCount = 0,
|
||||
ClusterNumber = 0,
|
||||
PvNumber = 0,
|
||||
|
|
@ -67,6 +83,10 @@ public class Config
|
|||
{
|
||||
var configFilePath = path ?? DefaultConfigFilePath;
|
||||
|
||||
var currentWriteTime = File.GetLastWriteTimeUtc(configFilePath);
|
||||
if (currentWriteTime != LoadedWriteTimeUtc)
|
||||
throw new IOException("Config file changed on disk since it was loaded; refusing to overwrite."); // to prevent an overwriting while an external changes happended in the meantime
|
||||
|
||||
try
|
||||
{
|
||||
var jsonString = Serialize(this, JsonOptions);
|
||||
|
|
@ -84,13 +104,15 @@ public class Config
|
|||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
public static Config Load(String? path = null)
|
||||
{
|
||||
var configFilePath = path ?? DefaultConfigFilePath;
|
||||
try
|
||||
{
|
||||
var jsonString = File.ReadAllText(configFilePath);
|
||||
// LoadedWriteTimeUtc = File.GetLastWriteTimeUtc(configFilePath);
|
||||
|
||||
return Deserialize<Config>(jsonString)!;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -98,6 +120,31 @@ public class Config
|
|||
$"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine();
|
||||
return Default;
|
||||
}
|
||||
}*/
|
||||
|
||||
public static Config Load(string? path = null)
|
||||
{
|
||||
var configFilePath = path ?? DefaultConfigFilePath; // ✅ handle null first
|
||||
|
||||
try
|
||||
{
|
||||
// Now safe to call any File/Path API
|
||||
var json = File.ReadAllText(configFilePath);
|
||||
|
||||
var cfg = Deserialize<Config>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Config deserialized to null.");
|
||||
|
||||
// Optional: store last write time / last saved json
|
||||
LoadedWriteTimeUtc = File.GetLastWriteTimeUtc(configFilePath);
|
||||
LastSavedData = Serialize(cfg, JsonOptions); // if you use the save-skip logic
|
||||
|
||||
return cfg;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
$"Failed to read config file {configFilePath}\n{e}".WriteLine();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<Config> LoadAsync(String? path = null)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
|
||||
|
||||
public record DeviceConfig
|
||||
{
|
||||
public required SerialLineConfig Serial { get; init; }
|
||||
|
||||
public required SodiDevice Inverter1 { get; init; }
|
||||
public required SodiDevice Inverter2 { get; init; }
|
||||
public required SodiDevice Inverter3 { get; init; }
|
||||
public required SodiDevice Inverter4 { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
|
||||
|
||||
public enum DeviceState
|
||||
{
|
||||
Disabled,
|
||||
Measured,
|
||||
Computed
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using System.IO.Ports;
|
||||
|
||||
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
|
||||
|
||||
public sealed class SerialLineConfig
|
||||
{
|
||||
public required Int32 BaudRate { get; init; } = 115200;
|
||||
public required Parity Parity { get; init; } = 0; //none
|
||||
public required Int32 DataBits { get; init; } = 8;
|
||||
public required Int32 StopBits { get; init; } = 1;
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
|
||||
|
||||
public class SodiDevice
|
||||
{
|
||||
public required DeviceState DeviceState { get; init; }
|
||||
public required String Port { get; init; }
|
||||
public required Byte SlaveId { get; init; }
|
||||
}
|
||||
|
|
@ -6,12 +6,14 @@ username='inesco'
|
|||
root_password='Sodistore0918425'
|
||||
|
||||
release_flag_file="./bin/Release/$dotnet_version/linux-arm64/publish/.release.flag"
|
||||
DOTNET="/snap/dotnet-sdk_60/current/dotnet"
|
||||
|
||||
|
||||
set -e
|
||||
|
||||
echo -e "\n============================ Build ============================\n"
|
||||
|
||||
dotnet publish \
|
||||
"$DOTNET" publish \
|
||||
./SinexcelCommunication.csproj \
|
||||
-p:PublishTrimmed=false \
|
||||
-c Release \
|
||||
|
|
|
|||
|
|
@ -49,4 +49,10 @@ public enum SinexcelMachineMode
|
|||
{
|
||||
Single = 0, // Default
|
||||
Parallel = 1
|
||||
}
|
||||
|
||||
public enum EnablePowerLimitation
|
||||
{
|
||||
Prohibited = 0, // Default
|
||||
Enable = 1
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.ComponentModel;
|
||||
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
|
||||
using InnovEnergy.Lib.Units;
|
||||
using InnovEnergy.Lib.Units.Power;
|
||||
|
|
@ -193,7 +194,7 @@ public partial class SinexcelRecord
|
|||
|
||||
|
||||
|
||||
private Int16 _factorFromKwtoW = 1000;
|
||||
private readonly Int16 _factorFromKwtoW = 1000;
|
||||
// ───────────────────────────────────────────────
|
||||
// Public API — Decoded Float Values
|
||||
// ───────────────────────────────────────────────
|
||||
|
|
@ -295,11 +296,12 @@ public partial class SinexcelRecord
|
|||
public UInt16 Minute => (UInt16) ConvertBitPatternToFloat(_minute);
|
||||
public UInt16 Second => (UInt16) ConvertBitPatternToFloat(_second);
|
||||
|
||||
public DateTime SystemDateTime => new(Year, Month, Day, Hour, Minute, Second);
|
||||
// public DateTime SystemDateTime => new(Year, Month, Day, Hour, Minute, Second);
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Diesel Generator Measurements
|
||||
// ───────────────────────────────────────────────
|
||||
/*
|
||||
public Voltage DieselGenAPhaseVoltage => ConvertBitPatternToFloat(_dieselGenAPhaseVoltage);
|
||||
public Voltage DieselGenBPhaseVoltage => ConvertBitPatternToFloat(_dieselGenBPhaseVoltage);
|
||||
public Voltage DieselGenCPhaseVoltage => ConvertBitPatternToFloat(_dieselGenCPhaseVoltage);
|
||||
|
|
@ -324,7 +326,7 @@ public partial class SinexcelRecord
|
|||
|
||||
public ReactivePower DieselGenAPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenAPhaseReactivePower) * _factorFromKwtoW;
|
||||
public ReactivePower DieselGenBPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenBPhaseReactivePower) * _factorFromKwtoW;
|
||||
public ReactivePower DieselGenCPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenCPhaseReactivePower) * _factorFromKwtoW;
|
||||
public ReactivePower DieselGenCPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenCPhaseReactivePower) * _factorFromKwtoW;*/
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Photovoltaic and Battery Measurements
|
||||
|
|
@ -645,14 +647,14 @@ public partial class SinexcelRecord
|
|||
set => _battery2BackupSOC = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
|
||||
}
|
||||
// to be tested
|
||||
public float EnableGridExport
|
||||
public EnablePowerLimitation EnableGridExport
|
||||
|
||||
{
|
||||
get => BitConverter.Int32BitsToSingle(unchecked((int)_enableGridExport));
|
||||
set => _enableGridExport = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
|
||||
get => (EnablePowerLimitation)ConvertBitPatternToFloat(_enableGridExport); //(Boolean)BitConverter.Int32BitsToSingle(unchecked((int)_enableGridExport));
|
||||
set => _enableGridExport = (UInt32)value;
|
||||
}
|
||||
|
||||
public float PowerGridExportLimit
|
||||
public ActivePower PowerGridExportLimit
|
||||
{
|
||||
get => BitConverter.Int32BitsToSingle(unchecked((int)_powerGridExportLimit));
|
||||
set => _powerGridExportLimit = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
|
||||
|
|
@ -813,15 +815,17 @@ public partial class SinexcelRecord
|
|||
public Percent Battery1SocSecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soc)); // 0xB106 %
|
||||
public Percent Battery1Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soh)); // 0xB108 %
|
||||
|
||||
|
||||
// Energy (kW·h)
|
||||
public float Battery2TotalChargingEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalChargingEnergy)); // 0xB1FC
|
||||
public float Battery2TotalDischargedEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalDischargedEnergy)); // 0xB1FE
|
||||
|
||||
// Pack Voltage / Current / Temperature
|
||||
public Voltage Battery2PackTotalVoltage => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalVoltage)); // 0xB200 (0.01 V resolution)
|
||||
public Current Battery2PackTotalCurrent => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalCurrent)); // 0xB202 (0.01 A resolution)
|
||||
public Percent Battery2Socsecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soc)); // 0xB206 %
|
||||
public Percent Battery2Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soh)); // 0xB208 %
|
||||
public Voltage Battery2PackTotalVoltage => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalVoltage)); // 0xB200 (0.01 V resolution)
|
||||
public Current Battery2PackTotalCurrent => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalCurrent)); // 0xB202 (0.01 A resolution)
|
||||
public Temperature Battery2Temperature => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Temperature)); // 0xB104 (0.01 °C resolution per spec)
|
||||
public Percent Battery2Socsecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soc)); // 0xB206 %
|
||||
public Percent Battery2Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soh)); // 0xB208 %
|
||||
|
||||
|
||||
// Repetitive-week mask (bit-mapped 0–6 = Sun–Sat)
|
||||
|
|
@ -930,5 +934,9 @@ public partial class SinexcelRecord
|
|||
byte[] bytes = BitConverter.GetBytes(rawValue);
|
||||
return BitConverter.ToSingle(bytes, 0);
|
||||
}
|
||||
|
||||
|
||||
private static UInt32 ConvertFloatToBitPattern(float value)
|
||||
{
|
||||
return BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -290,16 +290,17 @@ public partial class SinexcelRecord
|
|||
// ───────────────────────────────────────────────
|
||||
// Date / Time Information
|
||||
// ───────────────────────────────────────────────
|
||||
[HoldingRegister<UInt32>(4338)] private UInt32 _year; // 0x10F2
|
||||
[HoldingRegister<UInt32>(4340)] private UInt32 _month; // 0x10F4
|
||||
[HoldingRegister<UInt32>(4342)] private UInt32 _day; // 0x10F6
|
||||
[HoldingRegister<UInt32>(4344)] private UInt32 _hour; // 0x10F8
|
||||
[HoldingRegister<UInt32>(4346)] private UInt32 _minute; // 0x10FA
|
||||
[HoldingRegister<UInt32>(4348)] private UInt32 _second; // 0x10FC
|
||||
[HoldingRegister<UInt32>(4338/*, writable: true*/)] private UInt32 _year; // 0x10F2
|
||||
[HoldingRegister<UInt32>(4340/*, writable: true*/)] private UInt32 _month; // 0x10F4
|
||||
[HoldingRegister<UInt32>(4342/*, writable: true*/)] private UInt32 _day; // 0x10F6
|
||||
[HoldingRegister<UInt32>(4344/*, writable: true*/)] private UInt32 _hour; // 0x10F8
|
||||
[HoldingRegister<UInt32>(4346/*, writable: true*/)] private UInt32 _minute; // 0x10FA
|
||||
[HoldingRegister<UInt32>(4348/*, writable: true*/)] private UInt32 _second; // 0x10FC
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Diesel Generator Measurements
|
||||
// ───────────────────────────────────────────────
|
||||
/*
|
||||
[HoldingRegister<UInt32>(4362)] private UInt32 _dieselGenAPhaseVoltage; // 0x110A
|
||||
[HoldingRegister<UInt32>(4364)] private UInt32 _dieselGenBPhaseVoltage; // 0x110C
|
||||
[HoldingRegister<UInt32>(4366)] private UInt32 _dieselGenCPhaseVoltage; // 0x110E
|
||||
|
|
@ -318,8 +319,8 @@ public partial class SinexcelRecord
|
|||
[HoldingRegister<UInt32>(4410)] private UInt32 _dieselGenBPhaseActivePower; // 0x113A
|
||||
[HoldingRegister<UInt32>(4412)] private UInt32 _dieselGenCPhaseActivePower; // 0x113C
|
||||
[HoldingRegister<UInt32>(4414)] private UInt32 _dieselGenAPhaseReactivePower; // 0x113E
|
||||
[HoldingRegister<UInt32>(4416)] private UInt32 _dieselGenBPhaseReactivePower; // 0x1140
|
||||
[HoldingRegister<UInt32>(4418)] private UInt32 _dieselGenCPhaseReactivePower; // 0x1142
|
||||
[HoldingRegister<UInt32>(4416)] private UInt32 _dieselGenBPhaseReactivePower; // 0x1140*
|
||||
[HoldingRegister<UInt32>(4418)] private UInt32 _dieselGenCPhaseReactivePower; // 0x1142*/
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Photovoltaic and Battery Measurements
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Inesco Energy</title>
|
||||
<title>inesco energy</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -34,15 +34,41 @@ function App() {
|
|||
const searchParams = new URLSearchParams(location.search);
|
||||
const username = searchParams.get('username');
|
||||
const {
|
||||
accessToSalimax,
|
||||
accessToSodiohome,
|
||||
accessToSodistore,
|
||||
accessToSodistoreGrid,
|
||||
accessToSodistorePro,
|
||||
setAccessToSalimax,
|
||||
setAccessToSalidomo,
|
||||
setAccessToSodiohome,
|
||||
setAccessToSodistore,
|
||||
setAccessToSodistoreGrid
|
||||
setAccessToSodistoreGrid,
|
||||
setAccessToSodistorePro
|
||||
} = useContext(ProductIdContext);
|
||||
|
||||
const defaultRoute = accessToSodiohome
|
||||
? routes.sodiohome_installations
|
||||
: accessToSodistorePro
|
||||
? routes.sodistorepro_installations
|
||||
: accessToSodistoreGrid
|
||||
? routes.sodistoregrid_installations
|
||||
: accessToSodistore
|
||||
? routes.sodistore_installations
|
||||
: accessToSalimax
|
||||
? routes.installations
|
||||
: routes.salidomo_installations;
|
||||
|
||||
const detectBrowserLanguage = (): string => {
|
||||
const browserLang = navigator.language?.toLowerCase() || '';
|
||||
if (browserLang.startsWith('de')) return 'de';
|
||||
if (browserLang.startsWith('fr')) return 'fr';
|
||||
if (browserLang.startsWith('it')) return 'it';
|
||||
return 'en';
|
||||
};
|
||||
|
||||
const [language, setLanguage] = useState<string>(
|
||||
() => localStorage.getItem('language') || currentUser?.language || 'en'
|
||||
() => localStorage.getItem('language') || currentUser?.language || detectBrowserLanguage()
|
||||
);
|
||||
|
||||
const onSelectLanguage = (lang: string) => {
|
||||
|
|
@ -94,11 +120,19 @@ function App() {
|
|||
const Login = Loader(lazy(() => import('src/components/login')));
|
||||
const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
|
||||
|
||||
const loginToResetPassword = () => {
|
||||
useEffect(() => {
|
||||
if (!username || token) return;
|
||||
|
||||
axiosConfigWithoutToken
|
||||
.post('/Login', null, { params: { username, password: '' } })
|
||||
.then((response) => {
|
||||
if (response.data && response.data.token) {
|
||||
// Clear the username param from URL to prevent re-login loops
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('username');
|
||||
url.searchParams.delete('reset');
|
||||
window.history.replaceState({}, '', url.pathname);
|
||||
|
||||
setNewToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
setAccessToSalimax(response.data.accessToSalimax);
|
||||
|
|
@ -106,25 +140,11 @@ function App() {
|
|||
setAccessToSodiohome(response.data.accessToSodioHome);
|
||||
setAccessToSodistore(response.data.accessToSodistoreMax);
|
||||
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
||||
if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else if (response.data.accessToSalidomo) {
|
||||
navigate(routes.salidomo_installations);
|
||||
} else if (response.data.accessToSodistoreMax) {
|
||||
navigate(routes.sodistore_installations);
|
||||
} else if (response.data.accessToSodistoreGrid) {
|
||||
navigate(routes.sodistoregrid_installations);
|
||||
} else {
|
||||
navigate(routes.sodiohome_installations);
|
||||
}
|
||||
setAccessToSodistorePro(response.data.accessToSodistorePro);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
if (username) {
|
||||
loginToResetPassword();
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
|
|
@ -158,8 +178,14 @@ function App() {
|
|||
if (token && currentUser?.mustResetPassword) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CssBaseline />
|
||||
<SetNewPassword></SetNewPassword>
|
||||
<IntlProvider
|
||||
messages={getTranslations()}
|
||||
locale={language}
|
||||
defaultLocale="en"
|
||||
>
|
||||
<CssBaseline />
|
||||
<SetNewPassword></SetNewPassword>
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -177,11 +203,11 @@ function App() {
|
|||
<Routes>
|
||||
<Route
|
||||
path={''}
|
||||
element={<Navigate to={routes.installations}></Navigate>}
|
||||
element={<Navigate to={defaultRoute}></Navigate>}
|
||||
></Route>
|
||||
<Route
|
||||
path={'login'}
|
||||
element={<Navigate to={routes.installations}></Navigate>}
|
||||
element={<Navigate to={defaultRoute}></Navigate>}
|
||||
></Route>
|
||||
<Route
|
||||
path="/"
|
||||
|
|
@ -228,6 +254,15 @@ function App() {
|
|||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={routes.sodistorepro_installations + '*'}
|
||||
element={
|
||||
<AccessContextProvider>
|
||||
<SodioHomeInstallationTabs product={5} />
|
||||
</AccessContextProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={routes.sodistoregrid_installations + '*'}
|
||||
element={
|
||||
|
|
@ -241,7 +276,7 @@ function App() {
|
|||
<Route path={routes.users + '*'} element={<Users />} />
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.installations}></Navigate>}
|
||||
element={<Navigate to={defaultRoute}></Navigate>}
|
||||
></Route>
|
||||
<Route path="ResetPassword" element={<ResetPassword />}></Route>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ export function formatPowerForGraph(value, magnitude): { value: number } {
|
|||
}
|
||||
}
|
||||
|
||||
const result = negative === false ? value : -value;
|
||||
return {
|
||||
value: negative === false ? value : -value
|
||||
value: Math.round(result * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -5,6 +5,7 @@
|
|||
"sodistore_installations": "/sodistore_installations/",
|
||||
"sodiohome_installations": "/sodiohome_installations/",
|
||||
"sodistoregrid_installations": "/sodistoregrid_installations/",
|
||||
"sodistorepro_installations": "/sodistorepro_installations/",
|
||||
"installation": "installation/",
|
||||
"login": "/login/",
|
||||
"forgotPassword": "/forgotPassword/",
|
||||
|
|
@ -24,5 +25,6 @@
|
|||
"detailed_view": "detailed_view/",
|
||||
"report": "report",
|
||||
"installationTickets": "installationTickets",
|
||||
"documents": "documents",
|
||||
"tickets": "/tickets/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const CURRENT_TERMS_VERSION = 2;
|
||||
|
||||
interface AcknowledgementDialogProps {
|
||||
open: boolean;
|
||||
onAcknowledge: () => void;
|
||||
}
|
||||
|
||||
const AcknowledgementDialog: React.FC<AcknowledgementDialogProps> = ({
|
||||
open,
|
||||
onAcknowledge
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="terms_dialog_title"
|
||||
defaultMessage="Welcome to inesco energy"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box mb={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="terms_data_heading"
|
||||
defaultMessage="Your Data"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="terms_data_body"
|
||||
defaultMessage="Your installation data is securely stored in Switzerland. We do not share your data with third parties."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="terms_ai_heading"
|
||||
defaultMessage="AI-Powered Insights"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="terms_ai_body"
|
||||
defaultMessage="We use an AI service hosted in the EU to provide diagnostics and insights for your installations. AI-generated results are recommendations and should be verified by qualified personnel."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="terms_cookies_heading"
|
||||
defaultMessage="Browser Storage"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="terms_cookies_body"
|
||||
defaultMessage="This platform stores login and preference settings in your browser to keep you signed in and remember your language choice."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={onAcknowledge}>
|
||||
<FormattedMessage
|
||||
id="terms_acknowledge_button"
|
||||
defaultMessage="I understand"
|
||||
/>
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcknowledgementDialog;
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface DataPrivacyDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DataPrivacyDialog: React.FC<DataPrivacyDialogProps> = ({
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="privacy_dialog_title"
|
||||
defaultMessage="Data & Privacy"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box mb={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_data_heading"
|
||||
defaultMessage="Where is my data stored?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_data_body"
|
||||
defaultMessage="Your installation data is stored on servers in Switzerland. Only authorized inesco energy personnel can access your data for support purposes."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_ai_heading"
|
||||
defaultMessage="How is AI used?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_ai_body"
|
||||
defaultMessage="We use an AI service hosted in the European Union to analyze your installation data and provide diagnostic insights. The AI processes technical data such as battery readings and error codes. AI recommendations should always be verified by qualified personnel."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_browser_heading"
|
||||
defaultMessage="What does my browser store?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_browser_body"
|
||||
defaultMessage="Your browser stores your login session to keep you signed in, and your language and theme preferences. No tracking or advertising cookies are used."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
<FormattedMessage
|
||||
id="privacy_access_heading"
|
||||
defaultMessage="Who has access to my data?"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<FormattedMessage
|
||||
id="privacy_access_body"
|
||||
defaultMessage="Your data is not shared with third parties. It is used solely to operate the platform and provide you with insights about your installations."
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id="privacy_close_button"
|
||||
defaultMessage="Close"
|
||||
/>
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataPrivacyDialog;
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Dialog,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
|
||||
export interface DocumentItem {
|
||||
id: number;
|
||||
ticketId?: number;
|
||||
ticketCommentId?: number;
|
||||
installationId?: number;
|
||||
scope: number;
|
||||
s3Key: string;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
uploadedByUserId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DocumentListProps {
|
||||
ticketId?: number;
|
||||
ticketCommentId?: number;
|
||||
installationId?: number;
|
||||
refreshKey?: number;
|
||||
canDelete?: boolean;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isImage(contentType: string): boolean {
|
||||
return contentType.startsWith('image/');
|
||||
}
|
||||
|
||||
function getFileIcon(contentType: string) {
|
||||
if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />;
|
||||
return <InsertDriveFileIcon fontSize="small" />;
|
||||
}
|
||||
|
||||
function DocumentList({
|
||||
ticketId,
|
||||
ticketCommentId,
|
||||
installationId,
|
||||
refreshKey = 0,
|
||||
canDelete = false
|
||||
}: DocumentListProps) {
|
||||
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
const [expandedImage, setExpandedImage] = useState<string | null>(null);
|
||||
|
||||
const fetchDocuments = () => {
|
||||
setLoading(true);
|
||||
axiosConfig
|
||||
.get('/GetDocuments', {
|
||||
params: { ticketId, ticketCommentId, installationId }
|
||||
})
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.data)) setDocuments(res.data);
|
||||
})
|
||||
.catch(() => setDocuments([]))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [ticketId, ticketCommentId, installationId, refreshKey]);
|
||||
|
||||
// Load image thumbnails
|
||||
useEffect(() => {
|
||||
documents.forEach((doc) => {
|
||||
if (isImage(doc.contentType) && !previews[doc.id]) {
|
||||
axiosConfig
|
||||
.get('/DownloadDocument', {
|
||||
params: { id: doc.id },
|
||||
responseType: 'blob'
|
||||
})
|
||||
.then((res) => {
|
||||
const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType }));
|
||||
setPreviews((prev) => ({ ...prev, [doc.id]: url }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
// Clean up blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = (doc: DocumentItem) => {
|
||||
if (previews[doc.id]) {
|
||||
const link = document.createElement('a');
|
||||
link.href = previews[doc.id];
|
||||
link.setAttribute('download', doc.originalName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return;
|
||||
}
|
||||
axiosConfig
|
||||
.get('/DownloadDocument', {
|
||||
params: { id: doc.id },
|
||||
responseType: 'blob'
|
||||
})
|
||||
.then((res) => {
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', doc.originalName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleDelete = (doc: DocumentItem) => {
|
||||
axiosConfig
|
||||
.delete('/DeleteDocument', { params: { id: doc.id } })
|
||||
.then(() => fetchDocuments())
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
if (documents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
<FormattedMessage id="attachments" defaultMessage="Attachments" />
|
||||
<Chip label={documents.length} size="small" sx={{ ml: 1 }} />
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{documents.map((doc) => (
|
||||
<ListItem
|
||||
key={doc.id}
|
||||
disableGutters
|
||||
sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 0.5 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
{!isImage(doc.contentType) && (
|
||||
<Box sx={{ mr: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{getFileIcon(doc.contentType)}
|
||||
</Box>
|
||||
)}
|
||||
<ListItemText
|
||||
primary={doc.originalName}
|
||||
secondary={`${formatFileSize(doc.sizeBytes)} — ${new Date(doc.createdAt).toLocaleDateString()}`}
|
||||
/>
|
||||
<Box sx={{ ml: 'auto', flexShrink: 0 }}>
|
||||
<IconButton size="small" onClick={() => handleDownload(doc)}>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{canDelete && (
|
||||
<IconButton size="small" onClick={() => handleDelete(doc)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{isImage(doc.contentType) && previews[doc.id] && (
|
||||
<Box
|
||||
component="img"
|
||||
src={previews[doc.id]}
|
||||
alt={doc.originalName}
|
||||
onClick={() => setExpandedImage(previews[doc.id])}
|
||||
sx={{
|
||||
maxWidth: 200,
|
||||
maxHeight: 150,
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': { opacity: 0.85 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Full-size image preview dialog */}
|
||||
<Dialog
|
||||
open={!!expandedImage}
|
||||
onClose={() => setExpandedImage(null)}
|
||||
maxWidth="lg"
|
||||
>
|
||||
{expandedImage && (
|
||||
<Box
|
||||
component="img"
|
||||
src={expandedImage}
|
||||
onClick={() => setExpandedImage(null)}
|
||||
sx={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentList;
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf'
|
||||
];
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
export interface UploadedDocument {
|
||||
id: number;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
scope: number; // 0 = TicketAttachment, 1 = InstallationDocument
|
||||
ticketId?: number;
|
||||
ticketCommentId?: number;
|
||||
installationId?: number;
|
||||
onUploaded?: (doc: UploadedDocument) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FileUploadButton({
|
||||
scope,
|
||||
ticketId,
|
||||
ticketCommentId,
|
||||
installationId,
|
||||
onUploaded,
|
||||
disabled = false
|
||||
}: FileUploadButtonProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
setError(`Invalid file type: ${file.name}`);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`File too large: ${file.name} (max 25 MB)`);
|
||||
return;
|
||||
}
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
setError('');
|
||||
setPendingFiles((prev) => [...prev, ...validFiles]);
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
|
||||
// Upload files sequentially to avoid race conditions
|
||||
uploadFilesSequentially(validFiles);
|
||||
};
|
||||
|
||||
const uploadFilesSequentially = async (files: File[]) => {
|
||||
setUploading(true);
|
||||
for (const file of files) {
|
||||
setProgress(0);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await axiosConfig.post('/UploadDocument', formData, {
|
||||
params: { scope, ticketId, ticketCommentId, installationId },
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setProgress(Math.round((e.loaded * 100) / e.total));
|
||||
}
|
||||
});
|
||||
setPendingFiles((prev) => prev.filter((f) => f !== file));
|
||||
if (onUploaded) onUploaded(res.data);
|
||||
} catch (err: any) {
|
||||
const serverMsg = err?.response?.data || err?.message || 'Unknown error';
|
||||
setError(`Failed to upload ${file.name}: ${serverMsg}`);
|
||||
setPendingFiles((prev) => prev.filter((f) => f !== file));
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ALLOWED_TYPES.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AttachFileIcon />}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || uploading}
|
||||
>
|
||||
<FormattedMessage id="attachFiles" defaultMessage="Attach Files" />
|
||||
</Button>
|
||||
|
||||
{uploading && (
|
||||
<Box sx={{ mt: 1, width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{pendingFiles.map((f, i) => (
|
||||
<Chip key={i} label={f.name} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" variant="caption" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileUploadButton;
|
||||
|
|
@ -18,7 +18,7 @@ function Footer() {
|
|||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1">
|
||||
© 2025 - Inesco Energy AG
|
||||
© 2025 - inesco energy AG
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
|
|
@ -33,7 +33,7 @@ function Footer() {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Inesco Energy AG
|
||||
inesco energy AG
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ function Logo() {
|
|||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TooltipWrapper title="inesco Energy" arrow>
|
||||
<TooltipWrapper title="inesco energy" arrow>
|
||||
<LogoWrapper to="/overview">
|
||||
<Badge
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ function Login() {
|
|||
setAccessToSalidomo,
|
||||
setAccessToSodiohome,
|
||||
setAccessToSodistore,
|
||||
setAccessToSodistoreGrid
|
||||
setAccessToSodistoreGrid,
|
||||
setAccessToSodistorePro
|
||||
} = useContext(ProductIdContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -86,16 +87,19 @@ function Login() {
|
|||
setAccessToSodiohome(response.data.accessToSodioHome);
|
||||
setAccessToSodistore(response.data.accessToSodistoreMax);
|
||||
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
|
||||
if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else if (response.data.accessToSalidomo) {
|
||||
navigate(routes.salidomo_installations);
|
||||
} else if (response.data.accessToSodistoreMax) {
|
||||
navigate(routes.sodistore_installations);
|
||||
setAccessToSodistorePro(response.data.accessToSodistorePro);
|
||||
if (response.data.accessToSodioHome) {
|
||||
navigate(routes.sodiohome_installations);
|
||||
} else if (response.data.accessToSodistorePro) {
|
||||
navigate(routes.sodistorepro_installations);
|
||||
} else if (response.data.accessToSodistoreGrid) {
|
||||
navigate(routes.sodistoregrid_installations);
|
||||
} else if (response.data.accessToSodistoreMax) {
|
||||
navigate(routes.sodistore_installations);
|
||||
} else if (response.data.accessToSalimax) {
|
||||
navigate(routes.installations);
|
||||
} else {
|
||||
navigate(routes.sodiohome_installations);
|
||||
navigate(routes.salidomo_installations);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,117 +1,85 @@
|
|||
import { Step } from 'react-joyride';
|
||||
import { IntlShape } from 'react-intl';
|
||||
|
||||
// --- Build a single step with i18n ---
|
||||
// --- Tab key → i18n content description mapping ---
|
||||
// Only the *content* (description) needs i18n keys.
|
||||
// The *title* is read directly from the rendered tab element's text,
|
||||
// so it always matches the tab label in the current language.
|
||||
|
||||
function makeStep(
|
||||
intl: IntlShape,
|
||||
target: string,
|
||||
titleId: string,
|
||||
contentId: string,
|
||||
placement: Step['placement'] = 'bottom',
|
||||
disableBeacon = false
|
||||
): Step {
|
||||
return {
|
||||
target,
|
||||
title: intl.formatMessage({ id: titleId }),
|
||||
content: intl.formatMessage({ id: contentId }),
|
||||
placement,
|
||||
...(disableBeacon ? { disableBeacon: true } : {})
|
||||
};
|
||||
}
|
||||
|
||||
// --- Tab key → i18n key mapping ---
|
||||
|
||||
const tabConfig: Record<string, { titleId: string; contentId: string }> = {
|
||||
list: { titleId: 'tourListTitle', contentId: 'tourListContent' },
|
||||
tree: { titleId: 'tourTreeTitle', contentId: 'tourTreeContent' },
|
||||
live: { titleId: 'tourLiveTitle', contentId: 'tourLiveContent' },
|
||||
overview: { titleId: 'tourOverviewTitle', contentId: 'tourOverviewContent' },
|
||||
batteryview: { titleId: 'tourBatteryviewTitle', contentId: 'tourBatteryviewContent' },
|
||||
pvview: { titleId: 'tourPvviewTitle', contentId: 'tourPvviewContent' },
|
||||
log: { titleId: 'tourLogTitle', contentId: 'tourLogContent' },
|
||||
information: { titleId: 'tourInformationTitle', contentId: 'tourInformationContent' },
|
||||
report: { titleId: 'tourReportTitle', contentId: 'tourReportContent' },
|
||||
manage: { titleId: 'tourManageTitle', contentId: 'tourManageContent' },
|
||||
configuration: { titleId: 'tourConfigurationTitle', contentId: 'tourConfigurationContent' },
|
||||
history: { titleId: 'tourHistoryTitle', contentId: 'tourHistoryContent' }
|
||||
const tabContentKey: Record<string, string> = {
|
||||
list: 'tourListContent',
|
||||
tree: 'tourTreeContent',
|
||||
live: 'tourLiveContent',
|
||||
overview: 'tourOverviewContent',
|
||||
batteryview: 'tourBatteryviewContent',
|
||||
pvview: 'tourPvviewContent',
|
||||
log: 'tourLogContent',
|
||||
information: 'tourInformationContent',
|
||||
report: 'tourReportContent',
|
||||
manage: 'tourManageContent',
|
||||
configuration: 'tourConfigurationContent',
|
||||
history: 'tourHistoryContent',
|
||||
installationTickets: 'tourInstallationTicketsContent'
|
||||
};
|
||||
|
||||
// Steps to skip inside a specific installation (already covered in the list-page tour)
|
||||
const listPageOnlyTabs = new Set(['list', 'tree']);
|
||||
|
||||
// --- Build tour steps from tab value list ---
|
||||
|
||||
function buildTourSteps(intl: IntlShape, tabValues: string[], includeInstallationHint = false, isInsideInstallation = false): Step[] {
|
||||
/**
|
||||
* Build tour steps dynamically from the DOM.
|
||||
* Scans for all rendered `#tour-tab-*` elements and creates a step for each.
|
||||
* The step title is the tab's own rendered text (matching across languages).
|
||||
*/
|
||||
export function buildDynamicTourSteps(intl: IntlShape, isInsideInstallation: boolean): Step[] {
|
||||
const steps: Step[] = [];
|
||||
|
||||
// Language selector step (only on list/tree pages, not inside an installation)
|
||||
if (!isInsideInstallation) {
|
||||
steps.push(makeStep(intl, '[data-tour="language-selector"]', 'tourLanguageTitle', 'tourLanguageContent', 'bottom', true));
|
||||
}
|
||||
for (const value of tabValues) {
|
||||
if (isInsideInstallation && listPageOnlyTabs.has(value)) continue;
|
||||
const cfg = tabConfig[value];
|
||||
if (cfg) {
|
||||
steps.push(makeStep(intl, `#tour-tab-${value}`, cfg.titleId, cfg.contentId, 'bottom', steps.length === 0));
|
||||
const langEl = document.querySelector('[data-tour="language-selector"]');
|
||||
if (langEl) {
|
||||
steps.push({
|
||||
target: '[data-tour="language-selector"]',
|
||||
title: intl.formatMessage({ id: 'tourLanguageTitle' }),
|
||||
content: intl.formatMessage({ id: 'tourLanguageContent' }),
|
||||
placement: 'bottom',
|
||||
disableBeacon: true
|
||||
});
|
||||
}
|
||||
}
|
||||
if (includeInstallationHint && !isInsideInstallation) {
|
||||
steps.push(makeStep(intl, '#tour-tab-list', 'tourExploreTitle', 'tourExploreContent'));
|
||||
|
||||
// Collect all tour-tab elements in DOM order
|
||||
const tabEls = document.querySelectorAll<HTMLElement>('[id^="tour-tab-"]');
|
||||
tabEls.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const tabValue = el.id.replace('tour-tab-', '');
|
||||
|
||||
// Skip list/tree tabs when inside an installation (already covered on list page)
|
||||
if (isInsideInstallation && (tabValue === 'list' || tabValue === 'tree')) return;
|
||||
|
||||
// Use the tab's own rendered text as title (matches current language)
|
||||
const title = el.textContent?.trim() || tabValue;
|
||||
const contentKey = tabContentKey[tabValue];
|
||||
const content = contentKey
|
||||
? intl.formatMessage({ id: contentKey })
|
||||
: '';
|
||||
|
||||
steps.push({
|
||||
target: `#tour-tab-${tabValue}`,
|
||||
title,
|
||||
content,
|
||||
placement: 'bottom' as const,
|
||||
...(steps.length === 0 ? { disableBeacon: true } : {})
|
||||
});
|
||||
});
|
||||
|
||||
// "Explore" hint at the end when on list/tree page
|
||||
if (!isInsideInstallation && document.querySelector('#tour-tab-list')) {
|
||||
steps.push({
|
||||
target: '#tour-tab-list',
|
||||
title: intl.formatMessage({ id: 'tourExploreTitle' }),
|
||||
content: intl.formatMessage({ id: 'tourExploreContent' }),
|
||||
placement: 'bottom'
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// --- Sodistore Home (product 2) ---
|
||||
|
||||
export const buildSodiohomeCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'live', 'overview', 'information', 'report'
|
||||
], false, inside);
|
||||
|
||||
export const buildSodiohomePartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'information', 'report'
|
||||
], true, inside);
|
||||
|
||||
export const buildSodiohomeAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'manage', 'information', 'configuration', 'history', 'report'
|
||||
], true, inside);
|
||||
|
||||
// --- Salimax (product 0) / Sodistore Max (product 3) ---
|
||||
|
||||
export const buildSalimaxCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'live', 'overview', 'information'
|
||||
], false, inside);
|
||||
|
||||
export const buildSalimaxPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'pvview', 'information'
|
||||
], true, inside);
|
||||
|
||||
export const buildSalimaxAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history', 'pvview'
|
||||
], true, inside);
|
||||
|
||||
// --- Sodistore Grid (product 4) — same as Salimax but no PV View ---
|
||||
|
||||
export const buildSodistoregridCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'live', 'overview', 'information'
|
||||
], false, inside);
|
||||
|
||||
export const buildSodistoregridPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'information'
|
||||
], true, inside);
|
||||
|
||||
export const buildSodistoregridAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history'
|
||||
], true, inside);
|
||||
|
||||
// --- Salidomo (product 1) ---
|
||||
|
||||
export const buildSalidomoCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'batteryview', 'overview', 'information'
|
||||
], false, inside);
|
||||
|
||||
export const buildSalidomoPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'batteryview', 'overview', 'information'
|
||||
], true, inside);
|
||||
|
||||
export const buildSalidomoAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
|
||||
'list', 'tree', 'batteryview', 'overview', 'log', 'manage', 'information', 'history'
|
||||
], true, inside);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0,
|
||||
Power: device?.[`Battery${batteryIndex}Power`] ?? 0,
|
||||
Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0,
|
||||
Soh: device?.[`Battery${batteryIndex}Soh`] ?? 0,
|
||||
}
|
||||
};
|
||||
} else {
|
||||
|
|
@ -69,7 +68,6 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
Current: inverter[`Battery${index}Current`] ?? 0,
|
||||
Power: inverter[`Battery${index}Power`] ?? 0,
|
||||
Soc: inverter[`Battery${index}Soc`] ?? 0,
|
||||
Soh: inverter[`Battery${index}Soh`] ?? 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -221,7 +219,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
|
||||
<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>
|
||||
{/*<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>*/}
|
||||
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
|
||||
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
|
||||
</TableRow>
|
||||
|
|
@ -240,12 +238,13 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
align="center"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
<Link
|
||||
{/* Detailed battery view commented out */}
|
||||
{/*<Link
|
||||
style={{ color: 'black' }}
|
||||
to={routes.detailed_view + BatteryId}
|
||||
>
|
||||
>*/}
|
||||
{'Battery Cluster ' + BatteryId}
|
||||
</Link>
|
||||
{/*</Link>*/}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
|
|
@ -293,7 +292,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
>
|
||||
{battery.Soc + ' %'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
{/*<TableCell
|
||||
sx={{
|
||||
width: '8%',
|
||||
textAlign: 'center',
|
||||
|
|
@ -307,7 +306,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
}}
|
||||
>
|
||||
{battery.Soh + ' %'}
|
||||
</TableCell>
|
||||
</TableCell>*/}
|
||||
{/*<TableCell*/}
|
||||
{/* sx={{*/}
|
||||
{/* width: '15%',*/}
|
||||
|
|
|
|||
|
|
@ -151,22 +151,25 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
pathsToSearch.push('Node' + i);
|
||||
}
|
||||
|
||||
const total = pathsToSearch.length;
|
||||
let i = 0;
|
||||
pathsToSearch.forEach((devicePath) => {
|
||||
if (
|
||||
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
|
||||
chartData[category].data[devicePath].data.length != 0
|
||||
) {
|
||||
// Spread color indices evenly across the palette for better contrast
|
||||
const colorIndex = total <= 1 ? 0 : Math.round(i * 9 / (total - 1));
|
||||
series.push({
|
||||
...chartData[category].data[devicePath],
|
||||
color:
|
||||
color === 'blue'
|
||||
? blueColors[i]
|
||||
? blueColors[colorIndex]
|
||||
: color === 'red'
|
||||
? redColors[i]
|
||||
? redColors[colorIndex]
|
||||
: color === 'green'
|
||||
? greenColors[i]
|
||||
: orangeColors[i]
|
||||
? greenColors[colorIndex]
|
||||
: orangeColors[colorIndex]
|
||||
});
|
||||
}
|
||||
i++;
|
||||
|
|
@ -751,63 +754,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Battery SoH Chart */}
|
||||
<Grid item md={12} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
marginTop: '10px',
|
||||
marginBottom: '30px'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ marginLeft: '20px' }}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" noWrap>
|
||||
<FormattedMessage
|
||||
id="battery_soh"
|
||||
defaultMessage="Battery SOH (State Of Health)"
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
pt: 3
|
||||
}}
|
||||
></Box>
|
||||
</Box>
|
||||
|
||||
<ReactApexChart
|
||||
options={{
|
||||
...getChartOptions(
|
||||
batteryViewDataArray[chartState].chartOverview.Soh,
|
||||
'daily',
|
||||
[],
|
||||
true
|
||||
),
|
||||
chart: {
|
||||
events: {
|
||||
beforeZoom: (chartContext, options) => {
|
||||
startZoom();
|
||||
handleBeforeZoom(chartContext, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
series={generateSeries(
|
||||
batteryViewDataArray[chartState].chartData,
|
||||
'Soh',
|
||||
'green'
|
||||
)}
|
||||
type="line"
|
||||
height={420}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Battery SoH Chart — removed for SodiostoreHome */}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useContext, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Container,
|
||||
Divider,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { UserContext } from 'src/contexts/userContext';
|
||||
import { UserType } from 'src/interfaces/UserTypes';
|
||||
import FileUploadButton from 'src/components/FileUploadButton';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
||||
interface DocumentsTabProps {
|
||||
installationId: number;
|
||||
}
|
||||
|
||||
function DocumentsTab({ installationId }: DocumentsTabProps) {
|
||||
const { currentUser } = useContext(UserContext);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const canDelete = currentUser?.userType === UserType.admin;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 3, mb: 3 }}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="installationDocuments"
|
||||
defaultMessage="Installation Documents"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<DocumentList
|
||||
installationId={installationId}
|
||||
refreshKey={refreshKey}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FileUploadButton
|
||||
scope={1}
|
||||
installationId={installationId}
|
||||
onUploaded={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||
<FormattedMessage
|
||||
id="documentsHint"
|
||||
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB."
|
||||
/>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentsTab;
|
||||
|
|
@ -57,7 +57,7 @@ function Information(props: InformationProps) {
|
|||
|
||||
const canEdit = currentUser.userType == UserType.admin;
|
||||
const isPartner = currentUser.userType == UserType.partner;
|
||||
const isSodistore = formValues.product === 3 || formValues.product === 4;
|
||||
const isSodistore = formValues.product === 3 || formValues.product === 4 || formValues.product === 5;
|
||||
|
||||
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
|
||||
const [loadingProviders, setLoadingProviders] = useState(false);
|
||||
|
|
@ -426,12 +426,18 @@ function Information(props: InformationProps) {
|
|||
label="S3 Bucket Name"
|
||||
name="s3bucketname"
|
||||
value={
|
||||
formValues.product === 0 || formValues.product == 3
|
||||
formValues.product === 0 || formValues.product === 3
|
||||
? formValues.s3BucketId +
|
||||
'-3e5b3069-214a-43ee-8d85-57d72000c19d'
|
||||
: formValues.product == 4
|
||||
: formValues.product === 4
|
||||
? formValues.s3BucketId +
|
||||
'-5109c126-e141-43ab-8658-f3c44c838ae8'
|
||||
: formValues.product === 5
|
||||
? formValues.s3BucketId +
|
||||
'-325c9373-9025-4a8d-bf5a-f9eedf1f155c'
|
||||
: formValues.product === 1
|
||||
? formValues.s3BucketId +
|
||||
'-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e'
|
||||
: formValues.s3BucketId +
|
||||
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
import {
|
||||
INSTALLATION_PRESETS,
|
||||
getPresetsForDevice,
|
||||
PresetConfig,
|
||||
BatterySnTree,
|
||||
parseBatterySnTree,
|
||||
|
|
@ -42,6 +42,8 @@ import {
|
|||
remapTree,
|
||||
computeFlatValues,
|
||||
wouldLoseData,
|
||||
SODIOHOME_DEVICE_TYPES,
|
||||
buildSodistoreProPreset,
|
||||
} from './installationSetupUtils';
|
||||
|
||||
interface InformationSodistorehomeProps {
|
||||
|
|
@ -60,7 +62,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const [formValues, setFormValues] = useState(props.values);
|
||||
const requiredFields = ['name', 'region', 'location', 'country'];
|
||||
const requiredFields = ['name'];
|
||||
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
|
||||
useState(false);
|
||||
const [pendingPreset, setPendingPreset] = useState<string | null>(null);
|
||||
|
|
@ -93,16 +95,25 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
return [value.trim()];
|
||||
};
|
||||
|
||||
const DeviceTypes = [
|
||||
{ id: 3, name: 'Growatt' },
|
||||
{ id: 4, name: 'Sinexcel' }
|
||||
];
|
||||
const isSodistorePro = props.values.product === 5;
|
||||
const DeviceTypes = isSodistorePro
|
||||
? [{ id: 4, name: 'inesco 12K - WR Hybrid' } as const]
|
||||
: SODIOHOME_DEVICE_TYPES;
|
||||
|
||||
// Preset state — initializes from persisted installationModel, empty for legacy
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>(
|
||||
props.values.installationModel || ''
|
||||
);
|
||||
const presetConfig: PresetConfig | null = INSTALLATION_PRESETS[selectedPreset] || null;
|
||||
const [inverterCount, setInverterCount] = useState<string>(
|
||||
isSodistorePro && props.values.installationModel
|
||||
? props.values.installationModel
|
||||
: ''
|
||||
);
|
||||
const presetConfig: PresetConfig | null = isSodistorePro
|
||||
? (inverterCount && parseInt(inverterCount, 10) > 0
|
||||
? buildSodistoreProPreset(parseInt(inverterCount, 10))
|
||||
: null)
|
||||
: (getPresetsForDevice(formValues.device)[selectedPreset] || null);
|
||||
|
||||
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
|
||||
if (presetConfig) {
|
||||
|
|
@ -130,8 +141,32 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
return Array.from({ length: invCount }, () => '1');
|
||||
});
|
||||
|
||||
// When presetConfig is available, ensure flat values (batteryClusterNumber, batteryNumber)
|
||||
// stay in sync with the current preset structure. This handles:
|
||||
// - Legacy installations with device=0 → user sets device type
|
||||
// - Preset structure changed (e.g., Growatt home 9 was [[1,1]] → now [[2]])
|
||||
useEffect(() => {
|
||||
if (!presetConfig) return;
|
||||
|
||||
// Re-parse battery tree if empty but serial numbers exist
|
||||
let tree = batterySnTree;
|
||||
if (tree.length === 0 && props.values.batterySerialNumbers) {
|
||||
tree = parseBatterySnTree(props.values.batterySerialNumbers, presetConfig);
|
||||
setBatterySnTree(tree);
|
||||
}
|
||||
|
||||
// Always recalculate flat values from current preset to keep DB in sync
|
||||
const flat = computeFlatValues(presetConfig, tree);
|
||||
if (
|
||||
flat.batteryClusterNumber !== formValues.batteryClusterNumber ||
|
||||
flat.batteryNumber !== formValues.batteryNumber
|
||||
) {
|
||||
setFormValues((prev) => ({ ...prev, ...flat }));
|
||||
}
|
||||
}, [presetConfig]);
|
||||
|
||||
const handlePresetChange = (newPreset: string) => {
|
||||
const newConfig = INSTALLATION_PRESETS[newPreset];
|
||||
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
|
||||
if (!newConfig) return;
|
||||
|
||||
// Check for data loss — either from existing tree or legacy flat data
|
||||
|
|
@ -160,7 +195,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
};
|
||||
|
||||
const applyPreset = (newPreset: string) => {
|
||||
const newConfig = INSTALLATION_PRESETS[newPreset];
|
||||
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
|
||||
if (!newConfig) return;
|
||||
|
||||
setSelectedPreset(newPreset);
|
||||
|
|
@ -202,6 +237,38 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const handleInverterCountChange = (value: string) => {
|
||||
if (value !== '' && !/^\d+$/.test(value)) return;
|
||||
if (value !== '' && parseInt(value, 10) > 20) return;
|
||||
setInverterCount(value);
|
||||
const count = parseInt(value, 10);
|
||||
if (isNaN(count) || count < 1) {
|
||||
setBatterySnTree([]);
|
||||
setFormValues({ ...formValues, installationModel: value });
|
||||
return;
|
||||
}
|
||||
const newConfig = buildSodistoreProPreset(count);
|
||||
const newTree = batterySnTree.length > 0
|
||||
? remapTree(batterySnTree, newConfig)
|
||||
: buildEmptyTree(newConfig);
|
||||
setBatterySnTree(newTree);
|
||||
const newInvSNs = Array.from({ length: count }, (_, i) => inverterSerialNumbers[i] || '');
|
||||
const newDlSNs = Array.from({ length: count }, (_, i) => dataloggerSerialNumbers[i] || '');
|
||||
const newPvStrings = Array.from({ length: count }, (_, i) => pvStringsPerInverter[i] || '1');
|
||||
setInverterSerialNumbers(newInvSNs);
|
||||
setDataloggerSerialNumbers(newDlSNs);
|
||||
setPvStringsPerInverter(newPvStrings);
|
||||
const flat = computeFlatValues(newConfig, newTree);
|
||||
setFormValues({
|
||||
...formValues,
|
||||
...flat,
|
||||
installationModel: value,
|
||||
inverterSN: newInvSNs.join('/'),
|
||||
dataloggerSN: newDlSNs.join('/'),
|
||||
pvStringsPerInverter: newPvStrings.join(','),
|
||||
});
|
||||
};
|
||||
|
||||
const handleInverterSnChange = (invIdx: number, value: string) => {
|
||||
const updated = [...inverterSerialNumbers];
|
||||
updated[invIdx] = value;
|
||||
|
|
@ -272,10 +339,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormValues({
|
||||
...formValues,
|
||||
[name]: value
|
||||
});
|
||||
const updated = { ...formValues, [name]: value };
|
||||
|
||||
// When device type changes, reset preset if it's not available for the new device
|
||||
if (name === 'device' && !isSodistorePro) {
|
||||
const newDevicePresets = getPresetsForDevice(Number(value));
|
||||
if (selectedPreset && !newDevicePresets[selectedPreset]) {
|
||||
setSelectedPreset('');
|
||||
setBatterySnTree([]);
|
||||
setInverterSerialNumbers([]);
|
||||
setDataloggerSerialNumbers([]);
|
||||
setPvStringsPerInverter([]);
|
||||
updated.installationModel = '';
|
||||
updated.batteryNumber = 0;
|
||||
updated.batteryClusterNumber = 0;
|
||||
updated.batterySerialNumbers = '';
|
||||
updated.inverterSN = '';
|
||||
updated.dataloggerSN = '';
|
||||
updated.pvStringsPerInverter = '';
|
||||
}
|
||||
}
|
||||
|
||||
setFormValues(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
|
|
@ -533,27 +618,45 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="region" defaultMessage="Region" />}
|
||||
name="region"
|
||||
value={formValues.region}
|
||||
label={<FormattedMessage id="street" defaultMessage="Street" />}
|
||||
name="street"
|
||||
value={formValues.street || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required={canEdit}
|
||||
error={canEdit && formValues.region === ''}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="location" defaultMessage="Location" />}
|
||||
name="location"
|
||||
value={formValues.location}
|
||||
label={<FormattedMessage id="postCode" defaultMessage="Postcode" />}
|
||||
name="postCode"
|
||||
value={formValues.postCode || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="city" defaultMessage="City" />}
|
||||
name="city"
|
||||
value={formValues.city || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="canton" defaultMessage="Canton" />}
|
||||
name="canton"
|
||||
value={formValues.canton || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required={canEdit}
|
||||
error={canEdit && formValues.location === ''}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -561,12 +664,21 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
<TextField
|
||||
label={<FormattedMessage id="country" defaultMessage="Country" />}
|
||||
name="country"
|
||||
value={formValues.country}
|
||||
value={formValues.country || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="distributionPartner" defaultMessage="Distribution Partner" />}
|
||||
name="distributionPartner"
|
||||
value={formValues.distributionPartner || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required={canEdit}
|
||||
error={canEdit && formValues.country === ''}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -583,6 +695,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!isSodistorePro && (
|
||||
<div>
|
||||
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
|
|
@ -602,6 +715,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Autocomplete
|
||||
|
|
@ -614,7 +728,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
onInputChange={(_e, val) =>
|
||||
setFormValues({ ...formValues, networkProvider: val || '' })
|
||||
}
|
||||
disabled={!canEdit && !isPartner}
|
||||
disabled={!canEdit}
|
||||
loading={loadingProviders}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
|
|
@ -636,6 +750,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="emailAddress" defaultMessage="Email Address" />}
|
||||
name="email"
|
||||
value={formValues.email || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
|
|
@ -652,7 +778,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
const val = e.target.value as string;
|
||||
setFormValues({ ...formValues, externalEms: val });
|
||||
}}
|
||||
inputProps={{ readOnly: !canEdit && !isPartner }}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
>
|
||||
<MenuItem value="No"><FormattedMessage id="emsNo" defaultMessage="No" /></MenuItem>
|
||||
<MenuItem value="Solar Manager">Solar Manager</MenuItem>
|
||||
|
|
@ -673,7 +799,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit && !isPartner }}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -696,13 +822,64 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
<FormattedMessage id="installationSetup" defaultMessage="Installation Setup" />
|
||||
</Typography>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="installationSerialNumber" defaultMessage="Installation Serial Number" />}
|
||||
name="serialNumber"
|
||||
value={formValues.serialNumber || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl sx={{ m: 1, width: '50ch' }}>
|
||||
<InputLabel
|
||||
shrink
|
||||
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
|
||||
>
|
||||
Installation Model
|
||||
<FormattedMessage id="couplingType" defaultMessage="AC/DC Coupling" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="couplingType"
|
||||
value={formValues.couplingType || 'DC'}
|
||||
onChange={handleChange}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
displayEmpty
|
||||
notched
|
||||
>
|
||||
<MenuItem value="AC">
|
||||
<FormattedMessage id="couplingAC" defaultMessage="AC-coupled" />
|
||||
</MenuItem>
|
||||
<MenuItem value="DC">
|
||||
<FormattedMessage id="couplingDC" defaultMessage="DC-coupled" />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{isSodistorePro ? (
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="numberOfInverters" defaultMessage="Number of Inverters" />}
|
||||
type="text"
|
||||
value={inverterCount}
|
||||
onChange={(e) => handleInverterCountChange(e.target.value)}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FormControl sx={{ m: 1, width: '50ch' }}>
|
||||
<InputLabel
|
||||
shrink
|
||||
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
|
||||
>
|
||||
<FormattedMessage id="installationModel" defaultMessage="Installation Model" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedPreset}
|
||||
|
|
@ -714,7 +891,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
<MenuItem value="" disabled>
|
||||
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
|
||||
</MenuItem>
|
||||
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||
{Object.keys(getPresetsForDevice(formValues.device)).map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
|
|
@ -722,12 +899,25 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="installationSerialNumber" defaultMessage="Installation Serial Number" />}
|
||||
name="serialNumber"
|
||||
value={formValues.serialNumber}
|
||||
label={<FormattedMessage id="inverterFirmwareVersion" defaultMessage="Inverter Firmware Version" />}
|
||||
name="inverterFirmwareVersion"
|
||||
value={formValues.inverterFirmwareVersion || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="batteryFirmwareVersion" defaultMessage="Battery Firmware Version" />}
|
||||
name="batteryFirmwareVersion"
|
||||
value={formValues.batteryFirmwareVersion || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
|
|
@ -746,7 +936,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
return (
|
||||
<Accordion
|
||||
key={`inv-${invIdx}`}
|
||||
defaultExpanded={!invSn}
|
||||
defaultExpanded={false}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<AccordionSummary
|
||||
|
|
@ -802,7 +992,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
return (
|
||||
<Accordion
|
||||
key={`cl-${invIdx}-${clIdx}`}
|
||||
defaultExpanded={true}
|
||||
defaultExpanded={false}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<AccordionSummary
|
||||
|
|
@ -870,7 +1060,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
<div>
|
||||
<TextField
|
||||
label="S3 Bucket Name"
|
||||
value={formValues.s3BucketId + '-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'}
|
||||
value={formValues.s3BucketId + '-' + (isSodistorePro ? '325c9373-9025-4a8d-bf5a-f9eedf1f155c' : 'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa')}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,41 @@
|
|||
export const SODIOHOME_DEVICE_TYPES = [
|
||||
{ id: 3, name: 'Growatt' },
|
||||
{ id: 4, name: 'inesco 12K - WR Hybrid' }
|
||||
] as const;
|
||||
|
||||
export const getDeviceTypeName = (deviceId: number): string =>
|
||||
SODIOHOME_DEVICE_TYPES.find(d => d.id === deviceId)?.name ?? '';
|
||||
|
||||
// [inverter][cluster] = batteryCount
|
||||
export type PresetConfig = number[][];
|
||||
|
||||
// 3D array: [inverter][cluster][batteryIndex] = serialNumber
|
||||
export type BatterySnTree = string[][][];
|
||||
|
||||
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = {
|
||||
'sodistore home 9': [[1, 1]],
|
||||
'sodistore home 18': [[2, 2]],
|
||||
'sodistore home 27': [[2, 2], [1, 1]],
|
||||
'sodistore home 36': [[2, 2], [2, 2]],
|
||||
// Device-aware presets: keyed by device ID, then model name
|
||||
// Device 3 = Growatt, Device 4 = inesco 12K
|
||||
export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>> = {
|
||||
3: {
|
||||
'sodistore home 9': [[2]],
|
||||
'sodistore home 18': [[4]],
|
||||
},
|
||||
4: {
|
||||
'sodistore home 9': [[1, 1]],
|
||||
'sodistore home 18': [[2, 2]],
|
||||
'sodistore home 27': [[2, 2], [1, 1]],
|
||||
'sodistore home 36': [[2, 2], [2, 2]],
|
||||
},
|
||||
};
|
||||
|
||||
export const getPresetsForDevice = (deviceId: number): Record<string, PresetConfig> =>
|
||||
INSTALLATION_PRESETS[deviceId] ?? {};
|
||||
|
||||
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
|
||||
Array.from({ length: inverterCount }, () => [2, 2]);
|
||||
|
||||
export const parseSodistoreProInverterCount = (model: string): number => {
|
||||
const n = parseInt(model, 10);
|
||||
return isNaN(n) || n < 1 ? 1 : n;
|
||||
};
|
||||
|
||||
export const buildEmptyTree = (preset: PresetConfig): BatterySnTree => {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import BatteryView from '../BatteryView/BatteryView';
|
|||
import Configuration from '../Configuration/Configuration';
|
||||
import PvView from '../PvView/PvView';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
import DocumentsTab from '../Documents/DocumentsTab';
|
||||
|
||||
interface singleInstallationProps {
|
||||
current_installation?: I_Installation;
|
||||
|
|
@ -150,7 +151,8 @@ function Installation(props: singleInstallationProps) {
|
|||
return false;
|
||||
}
|
||||
console.log(`Timestamp: ${timestamp}`);
|
||||
console.log(res[timestamp]);
|
||||
const { Config: { S3: { Key, Secret, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = res[timestamp] || {};
|
||||
console.log({ ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
|
||||
|
||||
setValues(res[timestamp]);
|
||||
await timeout(2000);
|
||||
|
|
@ -381,7 +383,7 @@ function Installation(props: singleInstallationProps) {
|
|||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'manage' &&
|
||||
// currentTab != 'manage' &&
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'installationTickets' && (
|
||||
<Container
|
||||
|
|
@ -538,7 +540,7 @@ function Installation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{/* {currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
path={routes.manage}
|
||||
element={
|
||||
|
|
@ -550,7 +552,7 @@ function Installation(props: singleInstallationProps) {
|
|||
</AccessContextProvider>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
|
|
@ -563,6 +565,17 @@ function Installation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
|
||||
<Route
|
||||
path={routes.documents}
|
||||
element={
|
||||
<DocumentsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.live}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export const fetchAggregatedDataJson = (
|
|||
} else if (r.status === 200) {
|
||||
const jsontext = await r.text();
|
||||
|
||||
if (product === 2) {
|
||||
if (product === 2 || product === 5) {
|
||||
return parseSinexcelAggregatedData(jsontext);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,14 +26,15 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
const tabList = [
|
||||
'live',
|
||||
'overview',
|
||||
'manage',
|
||||
// 'manage',
|
||||
'batteryview',
|
||||
'log',
|
||||
'information',
|
||||
'configuration',
|
||||
'history',
|
||||
'pvview',
|
||||
'installationTickets'
|
||||
'installationTickets',
|
||||
'documents'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -125,15 +126,15 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
)
|
||||
},
|
||||
|
||||
{
|
||||
value: 'manage',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="manage"
|
||||
defaultMessage="Access Management"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// value: 'manage',
|
||||
// label: (
|
||||
// <FormattedMessage
|
||||
// id="manage"
|
||||
// defaultMessage="Access Management"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
{
|
||||
value: 'log',
|
||||
label: <FormattedMessage id="log" defaultMessage="Log" />
|
||||
|
|
@ -170,6 +171,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -201,6 +206,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
label: (
|
||||
<FormattedMessage id="information" defaultMessage="Information" />
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -259,15 +268,15 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
value: 'pvview',
|
||||
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
|
||||
},
|
||||
{
|
||||
value: 'manage',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="manage"
|
||||
defaultMessage="Access Management"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// value: 'manage',
|
||||
// label: (
|
||||
// <FormattedMessage
|
||||
// id="manage"
|
||||
// defaultMessage="Access Management"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
{
|
||||
value: 'log',
|
||||
label: <FormattedMessage id="log" defaultMessage="Log" />
|
||||
|
|
@ -303,6 +312,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -348,6 +361,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
defaultMessage="Information"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -458,6 +475,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
<Navigate
|
||||
to={routes.sodistoregrid_installations + routes.list}
|
||||
/>
|
||||
) : props.product === 5 ? (
|
||||
<Navigate
|
||||
to={routes.sodistorepro_installations + routes.list}
|
||||
/>
|
||||
) : (
|
||||
<Navigate
|
||||
to={routes.sodistore_installations + routes.list}
|
||||
|
|
|
|||
|
|
@ -248,9 +248,9 @@ function Log(props: LogProps) {
|
|||
if (source === 'KnowledgeBase')
|
||||
return <Chip label="Knowledge Base" size="small" sx={{ bgcolor: '#1976d2', color: '#fff', fontWeight: 'bold' }} />;
|
||||
if (source === 'MistralAI')
|
||||
return <Chip label="Mistral AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />;
|
||||
return <Chip label="AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />;
|
||||
if (source === 'MistralFailed')
|
||||
return <Chip label="Mistral failed" size="small" color="error" />;
|
||||
return <Chip label="AI failed" size="small" color="error" />;
|
||||
return <Chip label="Not available" size="small" color="default" />;
|
||||
};
|
||||
|
||||
|
|
@ -288,7 +288,7 @@ function Log(props: LogProps) {
|
|||
onChange={e => { setDemoAlarm(e.target.value); setDemoResult(null); }}
|
||||
sx={{ minWidth: 260 }}
|
||||
>
|
||||
<ListSubheader>Sinexcel</ListSubheader>
|
||||
<ListSubheader>inesco 12K - WR Hybrid</ListSubheader>
|
||||
{DEMO_ALARMS.sinexcel.map(a => (
|
||||
<MenuItem key={a} value={a}>{a}</MenuItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import React, {
|
|||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Container,
|
||||
Divider,
|
||||
|
|
@ -19,6 +21,7 @@ import {
|
|||
MenuItem,
|
||||
Modal,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
|
|
@ -41,6 +44,16 @@ import {
|
|||
} from '../../../interfaces/InstallationTypes';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
|
||||
const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
|
||||
const PRODUCT_NAMES: Record<number, string> = {
|
||||
0: 'Salimax',
|
||||
1: 'Salidomo',
|
||||
2: 'Sodistore Home',
|
||||
3: 'Sodistore Max',
|
||||
4: 'Sodistore Grid',
|
||||
5: 'Sodistore Pro'
|
||||
};
|
||||
|
||||
interface UserAccessProps {
|
||||
current_user: InnovEnergyUser;
|
||||
}
|
||||
|
|
@ -57,16 +70,25 @@ function UserAccess(props: UserAccessProps) {
|
|||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
const [openFolder, setOpenFolder] = useState(false);
|
||||
const [openInstallation, setOpenInstallation] = useState(false);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
|
||||
const [selectedInstallationNames, setSelectedInstallationNames] = useState<string[]>([]);
|
||||
const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
|
||||
|
||||
// Available choices for grant modal
|
||||
const [availableFolders, setAvailableFolders] = useState<I_Folder[]>([]);
|
||||
const [availableInstallations, setAvailableInstallations] = useState<I_Installation[]>([]);
|
||||
|
||||
const sortedInstallations = useMemo(() => {
|
||||
const orderMap = new Map(PRODUCT_GROUP_ORDER.map((p, i) => [p, i]));
|
||||
const sorted = [...availableInstallations].sort((a, b) => {
|
||||
const oa = orderMap.get(a.product) ?? 99;
|
||||
const ob = orderMap.get(b.product) ?? 99;
|
||||
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
}, [availableInstallations]);
|
||||
|
||||
// Direct grants for this user
|
||||
const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]);
|
||||
const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]);
|
||||
|
|
@ -107,13 +129,14 @@ function UserAccess(props: UserAccessProps) {
|
|||
|
||||
const fetchAvailableInstallations = useCallback(async () => {
|
||||
try {
|
||||
const [res0, res1, res2, res3] = await Promise.all([
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=0`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=1`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=2`),
|
||||
axiosConfig.get(`/GetAllInstallationsFromProduct?product=3`)
|
||||
]);
|
||||
setAvailableInstallations([...res0.data, ...res1.data, ...res2.data, ...res3.data]);
|
||||
const products = [0, 1, 2, 3, 4, 5];
|
||||
const responses = await Promise.all(
|
||||
products.map((p) => axiosConfig.get(`/GetAllInstallationsFromProduct?product=${p}`))
|
||||
);
|
||||
const all = responses.flatMap((res, idx) =>
|
||||
res.data.map((inst: I_Installation) => ({ ...inst, product: products[idx] }))
|
||||
);
|
||||
setAvailableInstallations(all);
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 401) removeToken();
|
||||
}
|
||||
|
|
@ -128,7 +151,7 @@ function UserAccess(props: UserAccessProps) {
|
|||
fetchAvailableInstallations();
|
||||
setOpenModal(true);
|
||||
setSelectedFolderNames([]);
|
||||
setSelectedInstallationNames([]);
|
||||
setSelectedInstallations([]);
|
||||
};
|
||||
|
||||
const handleRevokeFolder = async (folderId: number, folderName: string) => {
|
||||
|
|
@ -176,8 +199,7 @@ function UserAccess(props: UserAccessProps) {
|
|||
});
|
||||
}
|
||||
|
||||
for (const installationName of selectedInstallationNames) {
|
||||
const installation = availableInstallations.find((i) => i.name === installationName);
|
||||
for (const installation of selectedInstallations) {
|
||||
await axiosConfig
|
||||
.post(`/GrantUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installation.id}`)
|
||||
.then(() => {
|
||||
|
|
@ -262,31 +284,43 @@ function UserAccess(props: UserAccessProps) {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl fullWidth sx={{ marginTop: 2, width: 390 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
<FormattedMessage id="grantAccessToInstallations" defaultMessage="Grant access to installations" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
<FormControl fullWidth sx={{ marginTop: 1, width: 390 }}>
|
||||
<Autocomplete<I_Installation, true, false, false>
|
||||
multiple
|
||||
value={selectedInstallationNames}
|
||||
onChange={(e) => setSelectedInstallationNames(e.target.value as string[])}
|
||||
open={openInstallation}
|
||||
onClose={() => setOpenInstallation(false)}
|
||||
onOpen={() => setOpenInstallation(true)}
|
||||
renderValue={(selected) => (
|
||||
<div>{selected.map((i) => <span key={i}>{i}, </span>)}</div>
|
||||
options={sortedInstallations}
|
||||
groupBy={(option) => PRODUCT_NAMES[option.product] || 'Unknown'}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={selectedInstallations}
|
||||
onChange={(_event, newValue) => setSelectedInstallations(newValue)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
renderGroup={(params) => (
|
||||
<li key={params.key}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: 13,
|
||||
padding: '4px 16px',
|
||||
backgroundColor: theme.colors.alpha.black[5],
|
||||
color: theme.colors.alpha.black[70]
|
||||
}}
|
||||
>
|
||||
{params.group}
|
||||
</Typography>
|
||||
<ul style={{ padding: 0 }}>{params.children}</ul>
|
||||
</li>
|
||||
)}
|
||||
>
|
||||
{availableInstallations.map((installation) => (
|
||||
<MenuItem key={installation.id} value={installation.name}>{installation.name}</MenuItem>
|
||||
))}
|
||||
<Button
|
||||
sx={{ marginLeft: '150px', marginTop: '10px', backgroundColor: theme.colors.primary.main, color: 'white', '&:hover': { backgroundColor: theme.colors.primary.dark }, padding: '6px 8px' }}
|
||||
onClick={() => setOpenInstallation(false)}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
</Select>
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={intl.formatMessage({ id: 'grantAccessToInstallations' })}
|
||||
placeholder={intl.formatMessage({ id: 'searchInstallations' })}
|
||||
InputLabelProps={{
|
||||
...params.InputLabelProps,
|
||||
sx: { fontSize: 14, backgroundColor: 'white' }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export const getChartOptions = (
|
|||
chartInfo: chartInfoInterface,
|
||||
type: string,
|
||||
dateList: string[],
|
||||
stacked: Boolean
|
||||
stacked: Boolean,
|
||||
voltageInfo?: chartInfoInterface
|
||||
): ApexOptions => {
|
||||
return type.includes('daily')
|
||||
? {
|
||||
|
|
@ -32,6 +33,7 @@ export const getChartOptions = (
|
|||
colors: ['#3498db', '#2ecc71', '#282828'],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
tickAmount: 8,
|
||||
labels: {
|
||||
datetimeFormatter: {
|
||||
year: 'yyyy',
|
||||
|
|
@ -51,6 +53,7 @@ export const getChartOptions = (
|
|||
? [
|
||||
{
|
||||
seriesName: 'Grid Power',
|
||||
tickAmount: 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
? 0
|
||||
|
|
@ -88,6 +91,7 @@ export const getChartOptions = (
|
|||
{
|
||||
seriesName: 'Grid Power',
|
||||
show: false,
|
||||
tickAmount: 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
? 0
|
||||
|
|
@ -104,15 +108,6 @@ export const getChartOptions = (
|
|||
: chartInfo.max <= 0
|
||||
? 0
|
||||
: undefined,
|
||||
title: {
|
||||
text: chartInfo.unit,
|
||||
style: {
|
||||
fontSize: '12px'
|
||||
},
|
||||
offsetY: -190,
|
||||
offsetX: 25,
|
||||
rotate: 0
|
||||
},
|
||||
labels: {
|
||||
formatter: function (value: number) {
|
||||
return formatPowerForGraph(
|
||||
|
|
@ -122,11 +117,39 @@ export const getChartOptions = (
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
seriesName: 'State Of Charge',
|
||||
seriesName: 'Grid Power',
|
||||
show: false,
|
||||
tickAmount: 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
? 0
|
||||
: chartInfo.max <= 0
|
||||
? Math.ceil(
|
||||
chartInfo.min / findPower(chartInfo.min).value
|
||||
) * findPower(chartInfo.min).value
|
||||
: undefined,
|
||||
max:
|
||||
chartInfo.min >= 0
|
||||
? Math.ceil(
|
||||
chartInfo.max / findPower(chartInfo.max).value
|
||||
) * findPower(chartInfo.max).value
|
||||
: chartInfo.max <= 0
|
||||
? 0
|
||||
: undefined,
|
||||
labels: {
|
||||
formatter: function (value: number) {
|
||||
return formatPowerForGraph(
|
||||
value,
|
||||
chartInfo.magnitude
|
||||
).value.toString();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
seriesName: 'Battery SOC',
|
||||
opposite: true,
|
||||
|
||||
tickAmount: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: {
|
||||
|
|
@ -140,12 +163,34 @@ export const getChartOptions = (
|
|||
},
|
||||
labels: {
|
||||
formatter: function (value: number) {
|
||||
return formatPowerForGraph(value, 0).value.toString();
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...(voltageInfo ? [{
|
||||
seriesName: 'Battery Voltage',
|
||||
opposite: true,
|
||||
tickAmount: 5,
|
||||
min: voltageInfo.min > 0 ? Math.floor(voltageInfo.min / 5) * 5 : 0,
|
||||
max: Math.ceil(voltageInfo.max / 5) * 5,
|
||||
title: {
|
||||
text: '(V)',
|
||||
style: {
|
||||
fontSize: '12px'
|
||||
},
|
||||
offsetY: -190,
|
||||
offsetX: -45,
|
||||
rotate: 0
|
||||
},
|
||||
labels: {
|
||||
formatter: function (value: number) {
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
}
|
||||
}] : [])
|
||||
]
|
||||
: {
|
||||
tickAmount: chartInfo.unit === '(%)' ? 5 : 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
? 0
|
||||
|
|
@ -173,6 +218,9 @@ export const getChartOptions = (
|
|||
},
|
||||
labels: {
|
||||
formatter: function (value: number) {
|
||||
if (chartInfo.unit === '(%)') {
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
return formatPowerForGraph(
|
||||
value,
|
||||
chartInfo.magnitude
|
||||
|
|
@ -189,7 +237,7 @@ export const getChartOptions = (
|
|||
y: {
|
||||
formatter: function (val, { seriesIndex, w }) {
|
||||
const seriesName = w.config.series[seriesIndex].name;
|
||||
if (seriesName === 'State Of Charge') {
|
||||
if (seriesName === 'Battery SOC') {
|
||||
return val.toFixed(2) + ' %';
|
||||
} else {
|
||||
return (
|
||||
|
|
@ -255,6 +303,7 @@ export const getChartOptions = (
|
|||
}
|
||||
},
|
||||
yaxis: {
|
||||
tickAmount: 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
? 0
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface OverviewProps {
|
|||
s3Credentials: I_S3Credentials;
|
||||
id: number;
|
||||
device?: number;
|
||||
product?: number;
|
||||
connected?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
|
@ -565,7 +566,7 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||
</Button>
|
||||
{props.device !== 3 && (
|
||||
{props.device !== 3 && props.product !== 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
|
|
@ -711,7 +712,8 @@ function Overview(props: OverviewProps) {
|
|||
dailyDataArray[chartState].chartOverview.overview,
|
||||
'dailyoverview',
|
||||
[],
|
||||
true
|
||||
true,
|
||||
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
|
||||
),
|
||||
chart: {
|
||||
events: {
|
||||
|
|
@ -734,11 +736,21 @@ function Overview(props: OverviewProps) {
|
|||
type: 'line',
|
||||
color: '#ff9900'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData.ACLoad,
|
||||
type: 'line',
|
||||
color: '#2ecc71'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData.soc,
|
||||
type: 'line',
|
||||
color: '#008FFB'
|
||||
}
|
||||
},
|
||||
...((product === 2 || product === 5) ? [{
|
||||
...dailyDataArray[chartState].chartData.batteryVoltage,
|
||||
type: 'line' as const,
|
||||
color: '#9b59b6'
|
||||
}] : [])
|
||||
]}
|
||||
height={420}
|
||||
/>
|
||||
|
|
@ -817,7 +829,7 @@ function Overview(props: OverviewProps) {
|
|||
type: 'bar',
|
||||
color: '#ff9900'
|
||||
},
|
||||
...(product !== 2 ? [{
|
||||
...((product !== 2 && product !== 5) ? [{
|
||||
name: 'Net Energy',
|
||||
color: '#e65100',
|
||||
type: 'line',
|
||||
|
|
@ -840,7 +852,7 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
{!(aggregatedData && product === 2) && (
|
||||
{!(aggregatedData && (product === 2 || product === 5)) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -933,7 +945,7 @@ function Overview(props: OverviewProps) {
|
|||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={(aggregatedData && product === 2) ? 12 : 6} xs={12}>
|
||||
<Grid item md={(aggregatedData && (product === 2 || product === 5)) ? 12 : 6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -1001,14 +1013,14 @@ function Overview(props: OverviewProps) {
|
|||
<ReactApexChart
|
||||
options={{
|
||||
...getChartOptions(
|
||||
product === 2
|
||||
(product === 2 || product === 5)
|
||||
? aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPowerWithoutHeating
|
||||
: aggregatedDataArray[aggregatedChartState]
|
||||
.chartOverview.dcPower,
|
||||
'weekly',
|
||||
aggregatedDataArray[aggregatedChartState].datelist,
|
||||
product === 2
|
||||
(product === 2 || product === 5)
|
||||
)
|
||||
}}
|
||||
series={[
|
||||
|
|
@ -1017,7 +1029,7 @@ function Overview(props: OverviewProps) {
|
|||
.chartData.dcChargingPower,
|
||||
color: '#008FFB'
|
||||
},
|
||||
...(product !== 2 ? [{
|
||||
...((product !== 2 && product !== 5) ? [{
|
||||
...aggregatedDataArray[aggregatedChartState]
|
||||
.chartData.heatingPower,
|
||||
color: '#ff9900'
|
||||
|
|
@ -1073,7 +1085,7 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
{product !== 2 && (
|
||||
{(product !== 2 && product !== 5) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -1136,7 +1148,7 @@ function Overview(props: OverviewProps) {
|
|||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
{product !== 2 && (
|
||||
{(product !== 2 && product !== 5) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
@ -1392,7 +1404,7 @@ function Overview(props: OverviewProps) {
|
|||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{aggregatedData && product === 2 && (
|
||||
{aggregatedData && (product === 2 || product === 5) && (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
|
|
@ -1457,7 +1469,7 @@ function Overview(props: OverviewProps) {
|
|||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
<Grid item md={product === 2 ? 12 : 6} xs={12}>
|
||||
<Grid item md={(product === 2 || product === 5) ? 12 : 6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
overflow: 'visible',
|
||||
|
|
@ -1518,7 +1530,7 @@ function Overview(props: OverviewProps) {
|
|||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
{product !== 2 && (
|
||||
{(product !== 2 && product !== 5) && (
|
||||
<Grid item md={6} xs={12}>
|
||||
<Card
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { UserType } from '../../../interfaces/UserTypes';
|
|||
import HistoryOfActions from '../History/History';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
import DocumentsTab from '../Documents/DocumentsTab';
|
||||
import AccessContextProvider from '../../../contexts/AccessContextProvider';
|
||||
import Access from '../ManageAccess/Access';
|
||||
|
||||
|
|
@ -105,7 +106,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
setLoading(false);
|
||||
console.log('NUMBER OF FILES=' + Object.keys(res).length);
|
||||
|
||||
console.log('res=', res);
|
||||
console.log('res= [S3 credentials hidden]');
|
||||
|
||||
while (continueFetching.current) {
|
||||
for (const timestamp of Object.keys(res)) {
|
||||
|
|
@ -114,7 +115,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
return false;
|
||||
}
|
||||
console.log(`Timestamp: ${timestamp}`);
|
||||
console.log('object is', res);
|
||||
const { Config: { S3: { Key, Secret, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = res[timestamp] || {};
|
||||
console.log('object is', { ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
|
||||
|
||||
// Set values asynchronously with delay
|
||||
setValues(res[timestamp]);
|
||||
|
|
@ -323,7 +325,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'manage' &&
|
||||
// currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'installationTickets' && (
|
||||
|
|
@ -416,7 +418,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{/* {currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
path={routes.manage}
|
||||
element={
|
||||
|
|
@ -428,7 +430,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
</AccessContextProvider>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
|
|
@ -441,6 +443,17 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
|
||||
<Route
|
||||
path={routes.documents}
|
||||
element={
|
||||
<DocumentsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.batteryview}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
const tabList = [
|
||||
'batteryview',
|
||||
'information',
|
||||
'manage',
|
||||
// 'manage',
|
||||
'overview',
|
||||
'log',
|
||||
'history',
|
||||
'installationTickets'
|
||||
'installationTickets',
|
||||
'documents'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -113,15 +114,15 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
label: <FormattedMessage id="log" defaultMessage="Log" />
|
||||
},
|
||||
|
||||
{
|
||||
value: 'manage',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="manage"
|
||||
defaultMessage="Access Management"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// value: 'manage',
|
||||
// label: (
|
||||
// <FormattedMessage
|
||||
// id="manage"
|
||||
// defaultMessage="Access Management"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
|
||||
{
|
||||
value: 'information',
|
||||
|
|
@ -141,6 +142,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -198,15 +203,15 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
label: <FormattedMessage id="log" defaultMessage="Log" />
|
||||
},
|
||||
|
||||
{
|
||||
value: 'manage',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="manage"
|
||||
defaultMessage="Access Management"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// value: 'manage',
|
||||
// label: (
|
||||
// <FormattedMessage
|
||||
// id="manage"
|
||||
// defaultMessage="Access Management"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
|
||||
{
|
||||
value: 'information',
|
||||
|
|
@ -226,6 +231,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentTab != 'list' &&
|
||||
|
|
|
|||
|
|
@ -56,19 +56,6 @@ interface HourlyEnergyRecord {
|
|||
|
||||
// ── Date Helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the Monday of the current week.
|
||||
*/
|
||||
function getCurrentMonday(): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dow = today.getDay(); // 0=Sun
|
||||
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - offset);
|
||||
return monday;
|
||||
}
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
|
|
@ -77,19 +64,20 @@ function formatDateISO(d: Date): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns current week Mon→yesterday. Today excluded because
|
||||
* S3 aggregated file is not available until end of day.
|
||||
* Returns the last 7 days ending yesterday.
|
||||
* Today is excluded because S3 aggregated file is not available until ~01:00 UTC the next day.
|
||||
*/
|
||||
function getCurrentWeekDays(currentMonday: Date): Date[] {
|
||||
function getLast7Days(): Date[] {
|
||||
const yesterday = new Date();
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const days: Date[] = [];
|
||||
|
||||
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) {
|
||||
days.push(new Date(d));
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(yesterday);
|
||||
d.setDate(yesterday.getDate() - i);
|
||||
days.push(d);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +93,6 @@ export default function DailySection({
|
|||
onPeriodChange?: (date: string) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
||||
const yesterday = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
|
|
@ -125,11 +112,8 @@ export default function DailySection({
|
|||
const [loadingWeek, setLoadingWeek] = useState(false);
|
||||
const [noData, setNoData] = useState(false);
|
||||
|
||||
// Current week Mon→yesterday only
|
||||
const weekDays = useMemo(
|
||||
() => getCurrentWeekDays(currentMonday),
|
||||
[currentMonday]
|
||||
);
|
||||
// Rolling 7-day window ending yesterday
|
||||
const weekDays = useMemo(() => getLast7Days(), []);
|
||||
|
||||
// Fetch data for current week days
|
||||
useEffect(() => {
|
||||
|
|
@ -193,7 +177,7 @@ export default function DailySection({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Day Strip — current week Mon→yesterday */}
|
||||
{/* Day Strip — last 7 days ending yesterday */}
|
||||
<DayStrip
|
||||
weekDays={weekDays}
|
||||
selectedDate={selectedDate}
|
||||
|
|
@ -344,7 +328,7 @@ function DayStrip({
|
|||
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||
<FormattedMessage
|
||||
id="currentWeekHint"
|
||||
defaultMessage="Current week (Mon–yesterday)"
|
||||
defaultMessage="Last 7 days"
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -353,9 +337,10 @@ function DayStrip({
|
|||
|
||||
// ── IntradayChart ────────────────────────────────────────────
|
||||
|
||||
const HOUR_LABELS = Array.from({ length: 24 }, (_, i) =>
|
||||
`${String(i).padStart(2, '0')}:00`
|
||||
);
|
||||
const HOUR_LABELS = [
|
||||
...Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`),
|
||||
'24:00'
|
||||
];
|
||||
|
||||
function IntradayChart({
|
||||
hourlyData,
|
||||
|
|
@ -387,13 +372,14 @@ function IntradayChart({
|
|||
|
||||
const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
|
||||
|
||||
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null);
|
||||
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null);
|
||||
const getHour = (i: number) => hourMap.get(i === 24 ? 23 : i);
|
||||
const pvData = HOUR_LABELS.map((_, i) => getHour(i)?.pvKwh ?? null);
|
||||
const loadData = HOUR_LABELS.map((_, i) => getHour(i)?.loadKwh ?? null);
|
||||
const batteryData = HOUR_LABELS.map((_, i) => {
|
||||
const h = hourMap.get(i);
|
||||
const h = getHour(i);
|
||||
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
|
||||
});
|
||||
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null);
|
||||
const socData = HOUR_LABELS.map((_, i) => getHour(i)?.battSoC ?? null);
|
||||
|
||||
const chartData = {
|
||||
labels: HOUR_LABELS,
|
||||
|
|
|
|||
|
|
@ -19,15 +19,18 @@ 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 { getDeviceTypeName } from '../Information/installationSetupUtils';
|
||||
|
||||
interface FlatInstallationViewProps {
|
||||
installations: I_Installation[];
|
||||
product?: number;
|
||||
}
|
||||
|
||||
const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
|
||||
const currentLocation = useLocation();
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
//
|
||||
const sortedInstallations = [...props.installations].sort((a, b) => {
|
||||
// Compare the status field of each installation and sort them based on the status.
|
||||
|
|
@ -50,7 +53,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
setSelectedInstallation(-1);
|
||||
|
||||
navigate(
|
||||
routes.sodiohome_installations +
|
||||
baseRoute +
|
||||
routes.list +
|
||||
routes.installation +
|
||||
`${installationID}` +
|
||||
|
|
@ -81,9 +84,9 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
sx={{
|
||||
display:
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + 'list' ||
|
||||
baseRoute + 'list' ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + routes.list
|
||||
baseRoute + routes.list
|
||||
? 'block'
|
||||
: 'none'
|
||||
}}
|
||||
|
|
@ -96,14 +99,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<TableCell>
|
||||
<FormattedMessage id="name" defaultMessage="Name" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="location" defaultMessage="Location" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="region" defaultMessage="Region" />
|
||||
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="canton" defaultMessage="Canton" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="country" defaultMessage="Country" />
|
||||
|
|
@ -146,19 +149,6 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color="text.primary"
|
||||
gutterBottom
|
||||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.location}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
|
@ -181,7 +171,20 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.region}
|
||||
{getDeviceTypeName(installation.device)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color="text.primary"
|
||||
gutterBottom
|
||||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.canton || ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
|||
import Overview from '../Overview/overview';
|
||||
import WeeklyReport from './WeeklyReport';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
import DocumentsTab from '../Documents/DocumentsTab';
|
||||
|
||||
interface singleInstallationProps {
|
||||
current_installation?: I_Installation;
|
||||
|
|
@ -50,7 +51,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
const s3Bucket =
|
||||
props.current_installation.s3BucketId.toString() +
|
||||
'-' +
|
||||
'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa';
|
||||
(props.current_installation.product === 5
|
||||
? '325c9373-9025-4a8d-bf5a-f9eedf1f155c'
|
||||
: 'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa');
|
||||
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
|
|
@ -181,7 +184,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
return false;
|
||||
}
|
||||
console.log(`Timestamp: ${timestamp}`);
|
||||
console.log(res[timestamp]);
|
||||
const { Config: { S3: { Key, Secret, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = res[timestamp] || {};
|
||||
console.log({ ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
|
||||
|
||||
setValues(res[timestamp]);
|
||||
await timeout(2000);
|
||||
|
|
@ -471,7 +475,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
</div>
|
||||
{loading &&
|
||||
currentTab != 'information' &&
|
||||
currentTab != 'manage' &&
|
||||
// currentTab != 'manage' &&
|
||||
currentTab != 'history' &&
|
||||
currentTab != 'log' &&
|
||||
currentTab != 'report' &&
|
||||
|
|
@ -582,7 +586,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{/* {currentUser.userType == UserType.admin && (
|
||||
<Route
|
||||
path={routes.manage}
|
||||
element={
|
||||
|
|
@ -594,7 +598,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
</AccessContextProvider>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<Route
|
||||
path={routes.overview}
|
||||
|
|
@ -603,6 +607,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
product={props.current_installation.product}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
|
|
@ -615,6 +620,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
element={
|
||||
<WeeklyReport
|
||||
installationId={props.current_installation.id}
|
||||
installationName={props.current_installation.name}
|
||||
installationEmail={props.current_installation.email}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
@ -631,6 +638,17 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
|
||||
<Route
|
||||
path={routes.documents}
|
||||
element={
|
||||
<DocumentsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.live}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import SodioHomeInstallation from './Installation';
|
|||
|
||||
interface installationSearchProps {
|
||||
installations: I_Installation[];
|
||||
product?: number;
|
||||
}
|
||||
|
||||
function InstallationSearch(props: installationSearchProps) {
|
||||
const intl = useIntl();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const currentLocation = useLocation();
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
// const [filteredData, setFilteredData] = useState(props.installations);
|
||||
|
||||
const indexedData = useMemo(() => {
|
||||
|
|
@ -46,9 +48,9 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
sx={{
|
||||
display:
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + 'list' ||
|
||||
baseRoute + 'list' ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + routes.list
|
||||
baseRoute + routes.list
|
||||
? 'block'
|
||||
: 'none'
|
||||
}}
|
||||
|
|
@ -79,7 +81,7 @@ function InstallationSearch(props: installationSearchProps) {
|
|||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<FlatInstallationView installations={filteredData} />
|
||||
<FlatInstallationView installations={filteredData} product={props.product} />
|
||||
<Routes>
|
||||
{filteredData.map((installation) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,48 +17,57 @@ import { Close as CloseIcon } from '@mui/icons-material';
|
|||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils';
|
||||
import { getPresetsForDevice, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
|
||||
|
||||
interface SodistorehomeInstallationFormPros {
|
||||
cancel: () => void;
|
||||
submit: () => void;
|
||||
parentid: number;
|
||||
product?: number;
|
||||
}
|
||||
|
||||
function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(true);
|
||||
const isSodistorePro = props.product === 5;
|
||||
const [formValues, setFormValues] = useState<Partial<I_Installation>>({
|
||||
name: '',
|
||||
region: '',
|
||||
location: '',
|
||||
country: '',
|
||||
vpnIp: '',
|
||||
installationModel: '',
|
||||
externalEms: 'No',
|
||||
...(isSodistorePro ? { device: 4 } : {}),
|
||||
});
|
||||
const requiredFields = ['name', 'location', 'country', 'vpnIp', 'installationModel'];
|
||||
const [inverterCount, setInverterCount] = useState('');
|
||||
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['device', 'installationModel'])];
|
||||
|
||||
const DeviceTypes = [
|
||||
{ id: 3, name: 'Growatt' },
|
||||
{ id: 4, name: 'Sinexcel' }
|
||||
];
|
||||
const DeviceTypes = isSodistorePro
|
||||
? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
|
||||
: SODIOHOME_DEVICE_TYPES;
|
||||
const installationContext = useContext(InstallationsContext);
|
||||
const { createInstallation, loading, setLoading, error, setError } =
|
||||
installationContext;
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
const updated = { ...formValues, [name]: value };
|
||||
|
||||
setFormValues({
|
||||
...formValues,
|
||||
[name]: value
|
||||
});
|
||||
// Reset preset when device type changes if current preset is invalid
|
||||
if (name === 'device' && !isSodistorePro) {
|
||||
const newDevicePresets = getPresetsForDevice(Number(value));
|
||||
if (formValues.installationModel && !newDevicePresets[formValues.installationModel]) {
|
||||
updated.installationModel = '';
|
||||
}
|
||||
}
|
||||
|
||||
setFormValues(updated);
|
||||
};
|
||||
const handleSubmit = async (e) => {
|
||||
setLoading(true);
|
||||
formValues.parentId = props.parentid;
|
||||
formValues.product = 2;
|
||||
formValues.product = props.product ?? 2;
|
||||
if (isSodistorePro) {
|
||||
formValues.installationModel = inverterCount;
|
||||
}
|
||||
const responseData = await createInstallation(formValues);
|
||||
props.submit();
|
||||
};
|
||||
|
|
@ -72,6 +81,9 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
return false;
|
||||
}
|
||||
}
|
||||
if (isSodistorePro && (!inverterCount || parseInt(inverterCount, 10) < 1)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -127,42 +139,6 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
error={formValues.name === ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="region" defaultMessage="Region" />}
|
||||
name="region"
|
||||
value={formValues.region}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={formValues.region === ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage id="location" defaultMessage="Location" />
|
||||
}
|
||||
name="location"
|
||||
value={formValues.location}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={formValues.location === ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage id="country" defaultMessage="Country" />
|
||||
}
|
||||
name="country"
|
||||
value={formValues.country}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={formValues.country === ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={<FormattedMessage id="VpnIp" defaultMessage="VpnIp" />}
|
||||
|
|
@ -174,10 +150,71 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isSodistorePro ? (
|
||||
<div>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="numberOfInverters"
|
||||
defaultMessage="Number of Inverters"
|
||||
/>
|
||||
}
|
||||
name="inverterCount"
|
||||
type="text"
|
||||
value={inverterCount}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '' || (/^\d+$/.test(val) && parseInt(val, 10) <= 20)) {
|
||||
setInverterCount(val);
|
||||
}
|
||||
}}
|
||||
required
|
||||
error={!inverterCount || parseInt(inverterCount, 10) < 1}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Device type must be selected before model — it determines available presets */}
|
||||
<div>
|
||||
<FormControl
|
||||
fullWidth
|
||||
required
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
width: 390
|
||||
}}
|
||||
>
|
||||
<InputLabel
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="DeviceType"
|
||||
defaultMessage="Device Type"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="device"
|
||||
value={formValues.device ?? ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{DeviceTypes.map((device) => (
|
||||
<MenuItem key={device.id} value={device.id}>
|
||||
{device.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl
|
||||
fullWidth
|
||||
required
|
||||
disabled={!formValues.device}
|
||||
error={formValues.installationModel === ''}
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
|
|
@ -201,7 +238,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
value={formValues.installationModel || ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||
{Object.keys(getPresetsForDevice(formValues.device as number)).map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
|
|
@ -209,40 +246,8 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl
|
||||
fullWidth
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
width: 390
|
||||
}}
|
||||
>
|
||||
<InputLabel
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="DeviceType"
|
||||
defaultMessage="Device Type"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="device"
|
||||
value={formValues.device}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{DeviceTypes.map((device) => (
|
||||
<MenuItem key={device.id} value={device.id}>
|
||||
{device.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
<div
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,6 +13,8 @@ import TreeView from '../Tree/treeView';
|
|||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import SodioHomeInstallation from './Installation';
|
||||
import AcknowledgementDialog, { CURRENT_TERMS_VERSION } from '../../../components/AcknowledgementDialog';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
|
||||
interface SodioHomeInstallationTabsProps {
|
||||
product: number;
|
||||
|
|
@ -21,18 +23,37 @@ interface SodioHomeInstallationTabsProps {
|
|||
function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||
const location = useLocation();
|
||||
const context = useContext(UserContext);
|
||||
const { currentUser } = context;
|
||||
const { currentUser, setUser } = context;
|
||||
|
||||
const showTermsDialog =
|
||||
currentUser?.acknowledgedTermsVersion == null ||
|
||||
currentUser.acknowledgedTermsVersion < CURRENT_TERMS_VERSION;
|
||||
|
||||
const handleAcknowledgeTerms = () => {
|
||||
axiosConfig
|
||||
.put('/AcknowledgeTerms', undefined, {
|
||||
params: { version: CURRENT_TERMS_VERSION }
|
||||
})
|
||||
.then(() => {
|
||||
const updatedUser = {
|
||||
...currentUser,
|
||||
acknowledgedTermsVersion: CURRENT_TERMS_VERSION
|
||||
};
|
||||
setUser(updatedUser);
|
||||
});
|
||||
};
|
||||
const tabList = [
|
||||
'live',
|
||||
'overview',
|
||||
'batteryview',
|
||||
'information',
|
||||
'manage',
|
||||
// 'manage',
|
||||
'log',
|
||||
'history',
|
||||
'configuration',
|
||||
'report',
|
||||
'installationTickets'
|
||||
'installationTickets',
|
||||
'documents'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -40,12 +61,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
useState<boolean>(false);
|
||||
const {
|
||||
sodiohomeInstallations,
|
||||
sodistoreProInstallations,
|
||||
fetchAllInstallations,
|
||||
socket,
|
||||
openSocket,
|
||||
closeSocket
|
||||
} = useContext(InstallationsContext);
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
|
||||
const installations = props.product === 5 ? sodistoreProInstallations : sodiohomeInstallations;
|
||||
|
||||
useEffect(() => {
|
||||
let path = location.pathname.split('/');
|
||||
|
|
@ -119,15 +143,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
value: 'log',
|
||||
label: <FormattedMessage id="log" defaultMessage="Log" />
|
||||
},
|
||||
{
|
||||
value: 'manage',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="manage"
|
||||
defaultMessage="Access Management"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// value: 'manage',
|
||||
// label: (
|
||||
// <FormattedMessage
|
||||
// id="manage"
|
||||
// defaultMessage="Access Management"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
{
|
||||
value: 'information',
|
||||
label: (
|
||||
|
|
@ -164,6 +188,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -203,6 +231,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
defaultMessage="Report"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -237,11 +269,11 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
!location.pathname.includes('folder');
|
||||
|
||||
// Determine if current installation is Growatt (device=3) to hide report tab
|
||||
const currentInstallation = sodiohomeInstallations.find((i) =>
|
||||
const currentInstallation = installations.find((i) =>
|
||||
location.pathname.includes(`/${i.id}/`)
|
||||
);
|
||||
const isGrowatt = currentInstallation?.device === 3
|
||||
|| (sodiohomeInstallations.length === 1 && sodiohomeInstallations[0].device === 3);
|
||||
|| (installations.length === 1 && installations[0].device === 3);
|
||||
|
||||
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
||||
? [
|
||||
|
|
@ -274,15 +306,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
value: 'log',
|
||||
label: <FormattedMessage id="log" defaultMessage="Log" />
|
||||
},
|
||||
{
|
||||
value: 'manage',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="manage"
|
||||
defaultMessage="Access Management"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// value: 'manage',
|
||||
// label: (
|
||||
// <FormattedMessage
|
||||
// id="manage"
|
||||
// defaultMessage="Access Management"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
{
|
||||
value: 'information',
|
||||
label: (
|
||||
|
|
@ -319,6 +351,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: inInstallationView && currentUser.userType == UserType.partner
|
||||
|
|
@ -366,6 +402,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
defaultMessage="Report"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: inInstallationView && currentUser.userType == UserType.client
|
||||
|
|
@ -413,8 +453,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
}
|
||||
];
|
||||
|
||||
return sodiohomeInstallations.length > 1 ? (
|
||||
return installations.length > 1 ? (
|
||||
<>
|
||||
<AcknowledgementDialog
|
||||
open={showTermsDialog}
|
||||
onAcknowledge={handleAcknowledgeTerms}
|
||||
/>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
|
||||
<TabsContainerWrapper>
|
||||
<Tabs
|
||||
|
|
@ -459,7 +503,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
<Grid item xs={12}>
|
||||
<Box p={4}>
|
||||
<InstallationSearch
|
||||
installations={sodiohomeInstallations}
|
||||
installations={installations}
|
||||
product={props.product}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -472,7 +517,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
path={'*'}
|
||||
element={
|
||||
<Navigate
|
||||
to={routes.sodiohome_installations + routes.list}
|
||||
to={baseRoute + routes.list}
|
||||
></Navigate>
|
||||
}
|
||||
></Route>
|
||||
|
|
@ -481,9 +526,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
</Card>
|
||||
</Container>
|
||||
</>
|
||||
) : sodiohomeInstallations.length === 1 ? (
|
||||
) : installations.length === 1 ? (
|
||||
<>
|
||||
{' '}
|
||||
<AcknowledgementDialog
|
||||
open={showTermsDialog}
|
||||
onAcknowledge={handleAcknowledgeTerms}
|
||||
/>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
|
||||
<TabsContainerWrapper>
|
||||
<Tabs
|
||||
|
|
@ -523,7 +571,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
<Grid item xs={12}>
|
||||
<Box p={4}>
|
||||
<SodioHomeInstallation
|
||||
current_installation={sodiohomeInstallations[0]}
|
||||
current_installation={installations[0]}
|
||||
type="installation"
|
||||
></SodioHomeInstallation>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
|
|
@ -6,15 +6,19 @@ import {
|
|||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Chip,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
||||
interface CommentThreadProps {
|
||||
ticketId: number;
|
||||
|
|
@ -31,21 +35,67 @@ function CommentThread({
|
|||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (!ALLOWED_TYPES.includes(files[i].type) || files[i].size > MAX_FILE_SIZE) return;
|
||||
}
|
||||
setSelectedFiles((prev) => [...prev, ...Array.from(files)]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const sorted = [...comments].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!body.trim()) return;
|
||||
const handleSubmit = async () => {
|
||||
if (!body.trim() && selectedFiles.length === 0) return;
|
||||
setSubmitting(true);
|
||||
axiosConfig
|
||||
.post('/AddTicketComment', { ticketId, body })
|
||||
.then(() => {
|
||||
setBody('');
|
||||
onCommentAdded();
|
||||
})
|
||||
.finally(() => setSubmitting(false));
|
||||
|
||||
try {
|
||||
let commentId: number | undefined;
|
||||
if (body.trim()) {
|
||||
const res = await axiosConfig.post('/AddTicketComment', { ticketId, body });
|
||||
commentId = res.data?.id;
|
||||
}
|
||||
|
||||
if (selectedFiles.length > 0) {
|
||||
setUploading(true);
|
||||
for (const file of selectedFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
await axiosConfig.post('/UploadDocument', formData, {
|
||||
params: {
|
||||
scope: 0,
|
||||
ticketId,
|
||||
ticketCommentId: commentId
|
||||
},
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(`[Documents] Upload failed for ${file.name}:`, err?.response?.data || err?.message);
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
setBody('');
|
||||
setSelectedFiles([]);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onCommentAdded();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -100,6 +150,7 @@ function CommentThread({
|
|||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{comment.body}
|
||||
</Typography>
|
||||
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -107,25 +158,57 @@ function CommentThread({
|
|||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !body.trim()}
|
||||
sx={{ alignSelf: 'flex-end' }}
|
||||
>
|
||||
<FormattedMessage id="addComment" defaultMessage="Add" />
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting || uploading}
|
||||
>
|
||||
<AttachFileIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || uploading || (!body.trim() && selectedFiles.length === 0)}
|
||||
>
|
||||
<FormattedMessage id="addComment" defaultMessage="Add" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selectedFiles.map((f, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={f.name}
|
||||
size="small"
|
||||
onDelete={() => setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{uploading && <LinearProgress />}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
|
|
@ -10,10 +12,13 @@ import {
|
|||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import {
|
||||
|
|
@ -37,7 +42,8 @@ const productOptions = [
|
|||
{ value: 1, label: 'Salidomo' },
|
||||
{ value: 2, label: 'Sodistore Home' },
|
||||
{ value: 3, label: 'Sodistore Max' },
|
||||
{ value: 4, label: 'Sodistore Grid' }
|
||||
{ value: 4, label: 'Sodistore Grid' },
|
||||
{ value: 5, label: 'Sodistore Pro' }
|
||||
];
|
||||
|
||||
const deviceOptionsByProduct: Record<number, { value: number; label: string }[]> = {
|
||||
|
|
@ -47,7 +53,7 @@ const deviceOptionsByProduct: Record<number, { value: number; label: string }[]>
|
|||
],
|
||||
2: [
|
||||
{ value: 3, label: 'Growatt' },
|
||||
{ value: 4, label: 'Sinexcel' }
|
||||
{ value: 4, label: 'inesco 12K - WR Hybrid' }
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -75,6 +81,38 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// File attachments
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
setError(`Invalid file type: ${file.name}`);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`File too large: ${file.name} (max 25 MB)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError('');
|
||||
setSelectedFiles((prev) => [...prev, ...Array.from(files)]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Custom "Other" fields
|
||||
const [customSubCategory, setCustomSubCategory] = useState('');
|
||||
const [customCategory, setCustomCategory] = useState('');
|
||||
|
|
@ -200,16 +238,17 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
setDescription('');
|
||||
setCustomSubCategory('');
|
||||
setCustomCategory('');
|
||||
setSelectedFiles([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!subject.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
axiosConfig
|
||||
.post('/CreateTicket', {
|
||||
try {
|
||||
const res = await axiosConfig.post('/CreateTicket', {
|
||||
subject,
|
||||
description,
|
||||
installationId: selectedInstallation?.id ?? null,
|
||||
|
|
@ -218,14 +257,40 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
subCategory: isOtherCategory ? 0 : subCategory,
|
||||
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
|
||||
customCategory: isOtherCategory ? customCategory || null : null
|
||||
})
|
||||
.then(() => {
|
||||
resetForm();
|
||||
onCreated();
|
||||
onClose();
|
||||
})
|
||||
.catch(() => setError('Failed to create ticket.'))
|
||||
.finally(() => setSubmitting(false));
|
||||
});
|
||||
|
||||
const newTicketId = res.data?.id;
|
||||
|
||||
// Upload attached files if any
|
||||
if (selectedFiles.length > 0 && newTicketId) {
|
||||
setUploading(true);
|
||||
for (const file of selectedFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
await axiosConfig.post('/UploadDocument', formData, {
|
||||
params: { scope: 0, ticketId: newTicketId },
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(`[Documents] Upload failed for ${file.name}:`, err?.response?.data || err?.message);
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch {
|
||||
setError('Failed to create ticket.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableSubCategories = subCategoriesByCategory[category] ?? [];
|
||||
|
|
@ -441,6 +506,44 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
{/* File attachments */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AttachFileIcon />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting || uploading}
|
||||
>
|
||||
<FormattedMessage id="attachFiles" defaultMessage="Attach Files" />
|
||||
</Button>
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selectedFiles.map((f, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={f.name}
|
||||
size="small"
|
||||
onDelete={() => removeFile(i)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{uploading && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import StatusChip from './StatusChip';
|
|||
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||
import CommentThread from './CommentThread';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import FileUploadButton from 'src/components/FileUploadButton';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
|
||||
|
|
@ -87,6 +89,7 @@ function TicketDetailPage() {
|
|||
const [editingDescription, setEditingDescription] = useState(false);
|
||||
const [savingDescription, setSavingDescription] = useState(false);
|
||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
||||
|
||||
// Custom "Other" editing state
|
||||
const [editCustomSub, setEditCustomSub] = useState('');
|
||||
|
|
@ -381,6 +384,21 @@ function TicketDetailPage() {
|
|||
)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<DocumentList
|
||||
ticketId={ticket.id}
|
||||
refreshKey={docRefreshKey}
|
||||
canDelete={true}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FileUploadButton
|
||||
scope={0}
|
||||
ticketId={ticket.id}
|
||||
onUploaded={() => setDocRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -742,7 +760,8 @@ function TicketDetailPage() {
|
|||
1: routes.salidomo_installations,
|
||||
2: routes.sodiohome_installations,
|
||||
3: routes.sodistore_installations,
|
||||
4: routes.sodistoregrid_installations
|
||||
4: routes.sodistoregrid_installations,
|
||||
5: routes.sodistorepro_installations
|
||||
};
|
||||
const prefix = productRoutes[detail.installationProduct] ?? routes.installations;
|
||||
navigate(
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
? routes.salidomo_installations
|
||||
: installation.product == 2
|
||||
? routes.sodiohome_installations
|
||||
: installation.product == 4
|
||||
? routes.sodistoregrid_installations
|
||||
: installation.product == 5
|
||||
? routes.sodistorepro_installations
|
||||
: routes.sodistore_installations;
|
||||
|
||||
let folder_path =
|
||||
|
|
@ -69,6 +73,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
? routes.salidomo_installations
|
||||
: product == 2
|
||||
? routes.sodiohome_installations
|
||||
: product == 4
|
||||
? routes.sodistoregrid_installations
|
||||
: product == 5
|
||||
? routes.sodistorepro_installations
|
||||
: routes.sodistore_installations;
|
||||
|
||||
if (installation.type != 'Folder') {
|
||||
|
|
@ -209,6 +217,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
currentLocation.pathname === routes.installations + routes.tree ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodiohome_installations + routes.tree ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodistoregrid_installations + routes.tree ||
|
||||
currentLocation.pathname ===
|
||||
routes.sodistorepro_installations + routes.tree ||
|
||||
currentLocation.pathname.includes('folder')
|
||||
? 'block'
|
||||
: 'none',
|
||||
|
|
|
|||
|
|
@ -59,17 +59,18 @@ function TreeInformation(props: TreeInformationProps) {
|
|||
fetchAllInstallations
|
||||
} = installationContext;
|
||||
|
||||
const [product, setProduct] = useState('Salimax');
|
||||
const [product, setProduct] = useState('SodistoreHome');
|
||||
|
||||
const handleChangeInstallationChoice = (e) => {
|
||||
setProduct(e.target.value); // Directly update the product state
|
||||
};
|
||||
|
||||
const ProductTypes = ['Salimax', 'Salidomo', 'SodistoreHome', 'SodistoreMax', 'SodistoreGrid'];
|
||||
const ProductTypes = ['SodistoreHome', 'SodistorePro', 'SodistoreGrid', 'SodistoreMax', 'Salimax', 'Salidomo'];
|
||||
const ProductDisplayNames: Record<string, string> = {
|
||||
'SodistoreHome': 'Sodistore Home',
|
||||
'SodistoreMax': 'Sodistore Max',
|
||||
'SodistoreGrid': 'Sodistore Grid'
|
||||
'SodistoreGrid': 'Sodistore Grid',
|
||||
'SodistorePro': 'Sodistore Pro'
|
||||
};
|
||||
|
||||
const isMobile = window.innerWidth <= 1490;
|
||||
|
|
@ -345,11 +346,12 @@ function TreeInformation(props: TreeInformationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{openModalInstallation && product == 'SodistoreHome' && (
|
||||
{openModalInstallation && (product == 'SodistoreHome' || product == 'SodistorePro') && (
|
||||
<SodiostorehomeInstallationForm
|
||||
cancel={handleFormCancel}
|
||||
submit={handleInstallationFormSubmit}
|
||||
parentid={props.folder.id}
|
||||
product={product == 'SodistorePro' ? 5 : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
Modal,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
|
|
@ -20,6 +22,16 @@ import { TokenContext } from 'src/contexts/tokenContext';
|
|||
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
|
||||
const PRODUCT_NAMES: Record<number, string> = {
|
||||
0: 'Salimax',
|
||||
1: 'Salidomo',
|
||||
2: 'Sodistore Home',
|
||||
3: 'Sodistore Max',
|
||||
4: 'Sodistore Grid',
|
||||
5: 'Sodistore Pro'
|
||||
};
|
||||
|
||||
interface userFormProps {
|
||||
cancel: () => void;
|
||||
submit: () => void;
|
||||
|
|
@ -32,7 +44,6 @@ function userForm(props: userFormProps) {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' }));
|
||||
const [openInstallation, setOpenInstallation] = useState(false);
|
||||
const [openFolder, setOpenFolder] = useState(false);
|
||||
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
|
||||
name: '',
|
||||
|
|
@ -41,9 +52,7 @@ function userForm(props: userFormProps) {
|
|||
});
|
||||
const requiredFields = ['name', 'email'];
|
||||
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
|
||||
const [selectedInstallationNames, setSelectedInstallationNames] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
|
||||
|
||||
const UserTypes = ['Client', 'Partner', 'Admin'];
|
||||
|
||||
|
|
@ -72,22 +81,14 @@ function userForm(props: userFormProps) {
|
|||
setLoading(true);
|
||||
|
||||
try {
|
||||
// fetch product 0
|
||||
const res0 = await axiosConfig.get(
|
||||
`/GetAllInstallationsFromProduct?product=0`
|
||||
const products = [0, 1, 2, 3, 4, 5];
|
||||
const responses = await Promise.all(
|
||||
products.map((p) => axiosConfig.get(`/GetAllInstallationsFromProduct?product=${p}`))
|
||||
);
|
||||
const installations0 = res0.data;
|
||||
|
||||
// fetch product 1
|
||||
const res1 = await axiosConfig.get(
|
||||
`/GetAllInstallationsFromProduct?product=3`
|
||||
const combined = responses.flatMap((res, idx) =>
|
||||
res.data.map((inst: I_Installation) => ({ ...inst, product: products[idx] }))
|
||||
);
|
||||
const installations1 = res1.data;
|
||||
|
||||
// aggregate
|
||||
const combined = [...installations0, ...installations1];
|
||||
|
||||
// update
|
||||
setInstallations(combined);
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 401) {
|
||||
|
|
@ -98,6 +99,15 @@ function userForm(props: userFormProps) {
|
|||
}
|
||||
}, [setInstallations]);
|
||||
|
||||
const sortedInstallations = useMemo(() => {
|
||||
const orderMap = new Map(PRODUCT_GROUP_ORDER.map((p, i) => [p, i]));
|
||||
return [...installations].sort((a, b) => {
|
||||
const oa = orderMap.get(a.product) ?? 99;
|
||||
const ob = orderMap.get(b.product) ?? 99;
|
||||
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [installations]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFolders();
|
||||
fetchInstallations();
|
||||
|
|
@ -114,10 +124,6 @@ function userForm(props: userFormProps) {
|
|||
setSelectedFolderNames(event.target.value);
|
||||
};
|
||||
|
||||
const handleInstallationChange = (event) => {
|
||||
setSelectedInstallationNames(event.target.value);
|
||||
};
|
||||
|
||||
const isMobile = window.innerWidth <= 1490;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
|
|
@ -151,11 +157,7 @@ function userForm(props: userFormProps) {
|
|||
});
|
||||
}
|
||||
|
||||
for (const installationName of selectedInstallationNames) {
|
||||
const installation = installations.find(
|
||||
(installation) => installation.name === installationName
|
||||
);
|
||||
|
||||
for (const installation of selectedInstallations) {
|
||||
await axiosConfig
|
||||
.post(
|
||||
`/GrantUserAccessToInstallation?UserId=${res.data.id}&InstallationId=${installation.id}`
|
||||
|
|
@ -205,14 +207,6 @@ function userForm(props: userFormProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const handleOpenInstallation = () => {
|
||||
setOpenInstallation(true);
|
||||
};
|
||||
|
||||
const handleCloseInstallation = () => {
|
||||
setOpenInstallation(false);
|
||||
};
|
||||
|
||||
const handleOpenFolder = () => {
|
||||
setOpenFolder(true);
|
||||
};
|
||||
|
|
@ -343,55 +337,43 @@ function userForm(props: userFormProps) {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl fullWidth sx={{ marginTop: 2, width: 390 }}>
|
||||
<InputLabel
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="grantAccessToInstallations"
|
||||
defaultMessage="Grant access to installations"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedInstallationNames}
|
||||
onChange={handleInstallationChange}
|
||||
open={openInstallation}
|
||||
onClose={handleCloseInstallation}
|
||||
onOpen={handleOpenInstallation}
|
||||
renderValue={(selected) => (
|
||||
<div>
|
||||
{selected.map((installation) => (
|
||||
<span key={installation}>{installation}, </span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{installations.map((installation) => (
|
||||
<MenuItem key={installation.id} value={installation.name}>
|
||||
{installation.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<Button
|
||||
sx={{
|
||||
marginLeft: '150px',
|
||||
marginTop: '10px',
|
||||
backgroundColor: theme.colors.primary.main,
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.primary.dark
|
||||
},
|
||||
padding: '6px 8px'
|
||||
<Autocomplete<I_Installation, true, false, false>
|
||||
multiple
|
||||
options={sortedInstallations}
|
||||
groupBy={(option) => PRODUCT_NAMES[option.product] || 'Unknown'}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={selectedInstallations}
|
||||
onChange={(_event, newValue) => setSelectedInstallations(newValue)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
renderGroup={(params) => (
|
||||
<li key={params.key}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: 13,
|
||||
padding: '4px 16px',
|
||||
backgroundColor: theme.colors.alpha.black[5],
|
||||
color: theme.colors.alpha.black[70]
|
||||
}}
|
||||
>
|
||||
{params.group}
|
||||
</Typography>
|
||||
<ul style={{ padding: 0 }}>{params.children}</ul>
|
||||
</li>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={intl.formatMessage({ id: 'grantAccessToInstallations' })}
|
||||
placeholder={intl.formatMessage({ id: 'searchInstallations' })}
|
||||
InputLabelProps={{
|
||||
...params.InputLabelProps,
|
||||
sx: { fontSize: 14, backgroundColor: 'white' }
|
||||
}}
|
||||
onClick={handleCloseInstallation}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
</Select>
|
||||
</FormControl>
|
||||
/>
|
||||
)}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ function Status500() {
|
|||
<Container maxWidth="sm">
|
||||
<Box textAlign="center">
|
||||
<TypographyPrimary variant="h1" sx={{ my: 2 }}>
|
||||
inesco Energy{' '}
|
||||
inesco energy{' '}
|
||||
</TypographyPrimary>
|
||||
<TypographySecondary
|
||||
variant="h4"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ const InstallationsContextProvider = ({
|
|||
const [sodistoreGridInstallations, setSodistoreGridInstallations] = useState<
|
||||
I_Installation[]
|
||||
>([]);
|
||||
const [sodistoreProInstallations, setSodistoreProInstallations] = useState<
|
||||
I_Installation[]
|
||||
>([]);
|
||||
const [foldersAndInstallations, setFoldersAndInstallations] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
|
@ -105,10 +108,24 @@ const InstallationsContextProvider = ({
|
|||
}
|
||||
);
|
||||
|
||||
const updatedSodistorePro = sodistoreProInstallations.map(
|
||||
(installation) => {
|
||||
const update = pendingUpdates.current[installation.id];
|
||||
return update
|
||||
? {
|
||||
...installation,
|
||||
status: update.status,
|
||||
testingMode: update.testingMode
|
||||
}
|
||||
: installation;
|
||||
}
|
||||
);
|
||||
|
||||
setSalidomoInstallations(updatedSalidomo);
|
||||
setSalimax_Or_Sodistore_Installations(updatedSalimax);
|
||||
setSodiohomeInstallations(updatedSodiohome);
|
||||
setSodistoreGridInstallations(updatedSodistoreGrid);
|
||||
setSodistoreProInstallations(updatedSodistorePro);
|
||||
|
||||
// Clear the pending updates after applying
|
||||
pendingUpdates.current = {};
|
||||
|
|
@ -116,7 +133,8 @@ const InstallationsContextProvider = ({
|
|||
salidomoInstallations,
|
||||
salimax_or_sodistore_Installations,
|
||||
sodiohomeInstallations,
|
||||
sodistoreGridInstallations
|
||||
sodistoreGridInstallations,
|
||||
sodistoreProInstallations
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -193,6 +211,8 @@ const InstallationsContextProvider = ({
|
|||
|
||||
if (product === 2) {
|
||||
setSodiohomeInstallations(res.data);
|
||||
} else if (product === 5) {
|
||||
setSodistoreProInstallations(res.data);
|
||||
} else if (product === 1) {
|
||||
setSalidomoInstallations(res.data);
|
||||
} else if (product === 0 || product === 3) {
|
||||
|
|
@ -418,6 +438,7 @@ const InstallationsContextProvider = ({
|
|||
salidomoInstallations,
|
||||
sodiohomeInstallations,
|
||||
sodistoreGridInstallations,
|
||||
sodistoreProInstallations,
|
||||
foldersAndInstallations,
|
||||
fetchAllInstallations,
|
||||
fetchAllFoldersAndInstallations,
|
||||
|
|
@ -445,6 +466,7 @@ const InstallationsContextProvider = ({
|
|||
salidomoInstallations,
|
||||
sodiohomeInstallations,
|
||||
sodistoreGridInstallations,
|
||||
sodistoreProInstallations,
|
||||
foldersAndInstallations,
|
||||
loading,
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ interface ProductIdContextType {
|
|||
accessToSodiohome: boolean;
|
||||
accessToSodistore: boolean;
|
||||
accessToSodistoreGrid: boolean;
|
||||
accessToSodistorePro: boolean;
|
||||
setAccessToSalimax: (access: boolean) => void;
|
||||
setAccessToSalidomo: (access: boolean) => void;
|
||||
setAccessToSodiohome: (access: boolean) => void;
|
||||
setAccessToSodistore: (access: boolean) => void;
|
||||
setAccessToSodistoreGrid: (access: boolean) => void;
|
||||
setAccessToSodistorePro: (access: boolean) => void;
|
||||
}
|
||||
|
||||
// Create the context.
|
||||
|
|
@ -49,6 +51,10 @@ export const ProductIdContextProvider = ({
|
|||
const storedValue = localStorage.getItem('accessToSodistoreGrid');
|
||||
return storedValue === 'true';
|
||||
});
|
||||
const [accessToSodistorePro, setAccessToSodistorePro] = useState(() => {
|
||||
const storedValue = localStorage.getItem('accessToSodistorePro');
|
||||
return storedValue === 'true';
|
||||
});
|
||||
// const [product, setProduct] = useState(location.includes('salidomo') ? 1 : 0);
|
||||
// const [product, setProduct] = useState<number>(
|
||||
// productMapping[Object.keys(productMapping).find((key) => location.includes(key)) || ''] || -1
|
||||
|
|
@ -56,6 +62,8 @@ export const ProductIdContextProvider = ({
|
|||
const [product, setProduct] = useState<number>(() => {
|
||||
if (location.includes('salidomo')) {
|
||||
return 1;
|
||||
} else if (location.includes('sodistorepro')) {
|
||||
return 5;
|
||||
} else if (location.includes('sodiohome')) {
|
||||
return 2;
|
||||
} else if (location.includes('sodistoregrid')) {
|
||||
|
|
@ -92,6 +100,10 @@ export const ProductIdContextProvider = ({
|
|||
setAccessToSodistoreGrid(access);
|
||||
localStorage.setItem('accessToSodistoreGrid', JSON.stringify(access));
|
||||
};
|
||||
const changeAccessSodistorePro = (access: boolean) => {
|
||||
setAccessToSodistorePro(access);
|
||||
localStorage.setItem('accessToSodistorePro', JSON.stringify(access));
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductIdContext.Provider
|
||||
|
|
@ -103,11 +115,13 @@ export const ProductIdContextProvider = ({
|
|||
accessToSodiohome,
|
||||
accessToSodistore,
|
||||
accessToSodistoreGrid,
|
||||
accessToSodistorePro,
|
||||
setAccessToSalimax: changeAccessSalimax,
|
||||
setAccessToSalidomo: changeAccessSalidomo,
|
||||
setAccessToSodiohome: changeAccessSodiohome,
|
||||
setAccessToSodistore: changeAccessSodistore,
|
||||
setAccessToSodistoreGrid: changeAccessSodistoreGrid
|
||||
setAccessToSodistoreGrid: changeAccessSodistoreGrid,
|
||||
setAccessToSodistorePro: changeAccessSodistorePro
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface overviewInterface {
|
|||
overview: chartInfoInterface;
|
||||
ACLoad: chartInfoInterface;
|
||||
DCLoad: chartInfoInterface;
|
||||
batteryVoltage: chartInfoInterface;
|
||||
}
|
||||
|
||||
export interface chartAggregatedDataInterface {
|
||||
|
|
@ -53,6 +54,7 @@ export interface chartDataInterface {
|
|||
dcBusVoltage: { name: string; data: number[] };
|
||||
ACLoad: { name: string; data: number[] };
|
||||
DCLoad: { name: string; data: number[] };
|
||||
batteryVoltage: { name: string; data: number[] };
|
||||
}
|
||||
|
||||
export interface BatteryDataInterface {
|
||||
|
|
@ -86,7 +88,7 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
}> => {
|
||||
const prefixes = ['', 'k', 'M', 'G', 'T'];
|
||||
const MAX_NUMBER = 9999999;
|
||||
const isSodioHome = product === 2;
|
||||
const isSodioHome = product === 2 || product === 5;
|
||||
const categories = isSodioHome
|
||||
? ['Soc', 'Power', 'Voltage', 'Current', 'Soh']
|
||||
: ['Soc', 'Temperature', 'Power', 'Voltage', 'Current'];
|
||||
|
|
@ -169,7 +171,7 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
);
|
||||
|
||||
const adjustedTimestamp =
|
||||
product == 0 || product == 2 || product == 3 || product == 4
|
||||
product == 0 || product == 2 || product == 3 || product == 4 || product == 5
|
||||
? new Date(timestampArray[i] * 1000)
|
||||
: new Date(timestampArray[i] * 100000);
|
||||
//Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset
|
||||
|
|
@ -226,26 +228,52 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
categories.forEach((category) => {
|
||||
pathsToSave.forEach((path) => {
|
||||
if (pathsToSave.indexOf(path) >= old_length) {
|
||||
chartData[category].data[path] = { name: path, data: [] };
|
||||
const displayIndex = pathsToSave.indexOf(path);
|
||||
chartData[category].data[path] = { name: 'Battery Cluster ' + (displayIndex + 1), data: [] };
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Map category names to InverterRecord field suffixes
|
||||
const categoryFieldMap = {
|
||||
const hasDevices = !!inv?.Devices;
|
||||
|
||||
// Sinexcel field suffixes differ from Growatt for Voltage/Current
|
||||
const categoryFieldMapGrowatt = {
|
||||
Soc: 'Soc',
|
||||
Power: 'Power',
|
||||
Voltage: 'Voltage',
|
||||
Current: 'Current',
|
||||
Soh: 'Soh'
|
||||
};
|
||||
const categoryFieldMapSinexcel = {
|
||||
Soc: 'Soc',
|
||||
Power: 'Power',
|
||||
Voltage: 'PackTotalVoltage',
|
||||
Current: 'PackTotalCurrent',
|
||||
Soh: 'Soh'
|
||||
};
|
||||
|
||||
for (let j = 0; j < pathsToSave.length; j++) {
|
||||
const batteryIndex = j + 1; // Battery1, Battery2, ...
|
||||
categories.forEach((category) => {
|
||||
const fieldName = `Battery${batteryIndex}${categoryFieldMap[category]}`;
|
||||
const value = inv[fieldName];
|
||||
let value: number | undefined;
|
||||
|
||||
if (hasDevices) {
|
||||
// Sinexcel: nested under Devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, ...
|
||||
const deviceId = String(Math.floor(j / 2) + 1);
|
||||
const bi = (j % 2) + 1;
|
||||
const device = inv.Devices[deviceId];
|
||||
const fieldName = `Battery${bi}${categoryFieldMapSinexcel[category]}`;
|
||||
value = device?.[fieldName];
|
||||
// Fallback for Soc
|
||||
if ((value === undefined || value === null) && category === 'Soc') {
|
||||
value = device?.[`Battery${bi}SocSecondvalue`];
|
||||
}
|
||||
} else {
|
||||
// Growatt: flat Battery1Soc, Battery2Voltage, ...
|
||||
const batteryIndex = j + 1;
|
||||
const fieldName = `Battery${batteryIndex}${categoryFieldMapGrowatt[category]}`;
|
||||
value = inv[fieldName];
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
if (value < chartOverview[category].min) {
|
||||
|
|
@ -393,7 +421,7 @@ export const transformInputToDailyDataJson = async (
|
|||
// custom fallback logic to handle differences between Growatt and Sinexcel.
|
||||
// Growatt has: Battery1AmbientTemperature, GridPower, PvPower
|
||||
// Sinexcel has: Battery1Temperature, TotalGridPower (meter may be offline), PvPower1-4
|
||||
const pathsToSearch = product == 2
|
||||
const pathsToSearch = (product == 2 || product == 5)
|
||||
? [
|
||||
'SODIOHOME_SOC',
|
||||
'SODIOHOME_TEMPERATURE',
|
||||
|
|
@ -402,7 +430,8 @@ export const transformInputToDailyDataJson = async (
|
|||
'SODIOHOME_PV_POWER',
|
||||
null, // dcBusVoltage not available for SodioHome
|
||||
'SODIOHOME_CONSUMPTION',
|
||||
null // DCLoad not available for SodioHome
|
||||
null, // DCLoad not available for SodioHome
|
||||
'SODIOHOME_BATTERY_VOLTAGE'
|
||||
]
|
||||
: [
|
||||
'Battery.Soc',
|
||||
|
|
@ -412,7 +441,8 @@ export const transformInputToDailyDataJson = async (
|
|||
'PvOnDc',
|
||||
'DcDc.Dc.Link.Voltage',
|
||||
'LoadOnAcGrid.Power.Active',
|
||||
'LoadOnDc.Power'
|
||||
'LoadOnDc.Power',
|
||||
null // batteryVoltage not available for Salimax
|
||||
];
|
||||
const categories = [
|
||||
'soc',
|
||||
|
|
@ -422,18 +452,20 @@ export const transformInputToDailyDataJson = async (
|
|||
'pvProduction',
|
||||
'dcBusVoltage',
|
||||
'ACLoad',
|
||||
'DCLoad'
|
||||
'DCLoad',
|
||||
'batteryVoltage'
|
||||
];
|
||||
|
||||
const chartData: chartDataInterface = {
|
||||
soc: { name: 'State Of Charge', data: [] },
|
||||
soc: { name: 'Battery SOC', data: [] },
|
||||
temperature: { name: 'Battery Temperature', data: [] },
|
||||
dcPower: { name: 'Battery Power', data: [] },
|
||||
gridPower: { name: 'Grid Power', data: [] },
|
||||
pvProduction: { name: 'Pv Production', data: [] },
|
||||
pvProduction: { name: 'PV Power', data: [] },
|
||||
dcBusVoltage: { name: 'DC Bus Voltage', data: [] },
|
||||
ACLoad: { name: 'AC Load', data: [] },
|
||||
DCLoad: { name: 'DC Load', data: [] }
|
||||
DCLoad: { name: 'DC Load', data: [] },
|
||||
batteryVoltage: { name: 'Battery Voltage', data: [] }
|
||||
};
|
||||
|
||||
const chartOverview: overviewInterface = {
|
||||
|
|
@ -446,7 +478,8 @@ export const transformInputToDailyDataJson = async (
|
|||
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 }
|
||||
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
|
||||
};
|
||||
|
||||
categories.forEach((category) => {
|
||||
|
|
@ -504,7 +537,8 @@ export const transformInputToDailyDataJson = async (
|
|||
Object.keys(results[i]).length - 1
|
||||
];
|
||||
const result = results[i][timestamp];
|
||||
//console.log(result);
|
||||
const { Config: { S3: { Key: _k, Secret: _s, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = result || {};
|
||||
console.log('Overview data:', { ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
|
||||
let category_index = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
pathsToSearch.forEach((path) => {
|
||||
|
|
@ -516,8 +550,8 @@ export const transformInputToDailyDataJson = async (
|
|||
|
||||
let value: number | undefined = undefined;
|
||||
|
||||
if (product === 2) {
|
||||
// SodioHome: use top-level aggregated values (Sinexcel multi-inverter)
|
||||
if (product === 2 || product === 5) {
|
||||
// SodioHome/SodistorePro: use top-level aggregated values (Sinexcel multi-inverter)
|
||||
const inv = result?.InverterRecord;
|
||||
if (inv) {
|
||||
switch (category_index) {
|
||||
|
|
@ -539,6 +573,9 @@ export const transformInputToDailyDataJson = async (
|
|||
case 6: // consumption
|
||||
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
|
||||
break;
|
||||
case 8: // battery voltage
|
||||
value = inv.AvgBatteryVoltage ?? inv.Battery1Voltage;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (category_index === 4) {
|
||||
|
|
@ -609,20 +646,24 @@ export const transformInputToDailyDataJson = async (
|
|||
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
|
||||
chartOverview.DCLoad.unit =
|
||||
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
|
||||
chartOverview.batteryVoltage.unit = '(V)';
|
||||
|
||||
chartOverview.overview = {
|
||||
magnitude: Math.max(
|
||||
chartOverview['gridPower'].magnitude,
|
||||
chartOverview['pvProduction'].magnitude
|
||||
chartOverview['pvProduction'].magnitude,
|
||||
chartOverview['ACLoad'].magnitude
|
||||
),
|
||||
unit: '(kW)',
|
||||
min: Math.min(
|
||||
chartOverview['gridPower'].min,
|
||||
chartOverview['pvProduction'].min
|
||||
chartOverview['pvProduction'].min,
|
||||
chartOverview['ACLoad'].min
|
||||
),
|
||||
max: Math.max(
|
||||
chartOverview['gridPower'].max,
|
||||
chartOverview['pvProduction'].max
|
||||
chartOverview['pvProduction'].max,
|
||||
chartOverview['ACLoad'].max
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -647,13 +688,14 @@ const fetchJsonDataForOneTime = async (
|
|||
res = await fetchDataJson(timestampToFetch, s3Credentials);
|
||||
|
||||
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
|
||||
//console.log('Successfully fetched ' + timestampToFetch);
|
||||
console.log('Successfully fetched ' + timestampToFetch);
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
}
|
||||
}
|
||||
console.warn('Failed to fetch timestamp ' + startUnixTime.ticks);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -719,7 +761,8 @@ export const transformInputToAggregatedDataJson = async (
|
|||
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 }
|
||||
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
|
||||
};
|
||||
|
||||
pathsToSearch.forEach((path) => {
|
||||
|
|
@ -735,7 +778,7 @@ export const transformInputToAggregatedDataJson = async (
|
|||
const timestampPromises = [];
|
||||
|
||||
while (currentDay.isBefore(end_date)) {
|
||||
const dateFormat = product === 2
|
||||
const dateFormat = (product === 2 || product === 5)
|
||||
? currentDay.format('DDMMYYYY')
|
||||
: currentDay.format('YYYY-MM-DD');
|
||||
timestampPromises.push(
|
||||
|
|
@ -745,7 +788,7 @@ export const transformInputToAggregatedDataJson = async (
|
|||
}
|
||||
|
||||
const results = await Promise.all(timestampPromises);
|
||||
console.log("Fetched aggregated daily results:", results);
|
||||
console.log("Fetched aggregated daily results: [count=" + results.length + "]");
|
||||
currentDay = start_date;
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ export interface I_Installation extends I_S3Credentials {
|
|||
location: string;
|
||||
region: string;
|
||||
country: string;
|
||||
street?: string;
|
||||
postCode?: string;
|
||||
city?: string;
|
||||
canton?: string;
|
||||
distributionPartner?: string;
|
||||
inverterFirmwareVersion?: string;
|
||||
batteryFirmwareVersion?: string;
|
||||
installationName: string;
|
||||
vpnIp: string;
|
||||
orderNumbers: string[] | string;
|
||||
|
|
@ -21,6 +28,7 @@ export interface I_Installation extends I_S3Credentials {
|
|||
pvStringsPerInverter: string;
|
||||
installationModel: string;
|
||||
externalEms: string;
|
||||
couplingType: string;
|
||||
|
||||
parentId: number;
|
||||
s3WriteKey: string;
|
||||
|
|
@ -31,6 +39,7 @@ export interface I_Installation extends I_S3Credentials {
|
|||
status?: number;
|
||||
serialNumber?: string;
|
||||
networkProvider: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface I_Folder {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type InnovEnergyUser = {
|
|||
type: string;
|
||||
folderIds?: number[];
|
||||
mustResetPassword: boolean;
|
||||
acknowledgedTermsVersion?: number;
|
||||
};
|
||||
|
||||
export interface I_UserWithInheritedAccess {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,15 @@
|
|||
"alarms": "Alarme",
|
||||
"applyChanges": "Änderungen speichern",
|
||||
"country": "Land",
|
||||
"street": "Strasse",
|
||||
"postCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"canton": "Kanton",
|
||||
"distributionPartner": "Vertriebspartner",
|
||||
"inverterFirmwareVersion": "Wechselrichter-Firmware-Version",
|
||||
"batteryFirmwareVersion": "Batterie-Firmware-Version",
|
||||
"networkProvider": "Netzbetreiber",
|
||||
"emailAddress": "E-Mail-Adresse",
|
||||
"createNewFolder": "Neuer Ordner",
|
||||
"createNewUser": "Neuer Benutzer",
|
||||
"customerName": "Kundenname",
|
||||
|
|
@ -80,6 +88,9 @@
|
|||
"emsOther": "Andere",
|
||||
"generalInfo": "Allgemeine Informationen",
|
||||
"installationSetup": "Installationseinrichtung",
|
||||
"couplingType": "AC/DC-Kopplung",
|
||||
"couplingAC": "AC-gekoppelt",
|
||||
"couplingDC": "DC-gekoppelt",
|
||||
"selectModel": "Modell auswählen...",
|
||||
"inverterN": "Wechselrichter {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -109,6 +120,7 @@
|
|||
"deleteFolder": "Ordner löschen",
|
||||
"grantAccessToFolders": "Zugriff auf Ordner gewähren",
|
||||
"grantAccessToInstallations": "Zugriff auf Installationen gewähren",
|
||||
"searchInstallations": "Installationen suchen...",
|
||||
"cannotloadloggingdata": "Log Daten können nicht geladen werden",
|
||||
"grantedAccessToUsers": "Den Benutzern wurde den Zugriff gewährt",
|
||||
"unableToGrantAccess": "Der Zugriff kann nicht gewährt werden",
|
||||
|
|
@ -121,7 +133,6 @@
|
|||
"reportTitle": "Wöchentlicher Leistungsbericht",
|
||||
"weeklyInsights": "Wöchentliche Einblicke",
|
||||
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
|
||||
"weeklySavings": "Ihre Einsparungen diese Woche",
|
||||
"solarEnergyUsed": "Energie gespart",
|
||||
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
|
||||
"daysOfYourUsage": "Tage Ihres Verbrauchs",
|
||||
|
|
@ -129,10 +140,8 @@
|
|||
"atCHFRate": "bei 0,39 CHF/kWh Ø",
|
||||
"solarCoverage": "Energieunabhängigkeit",
|
||||
"fromSolarSub": "aus eigenem Solar + Batterie System",
|
||||
"avgDailyConsumption": "Ø Tagesverbrauch",
|
||||
"batteryEfficiency": "Batterieeffizienz",
|
||||
"batteryEffSub": "Entladung vs. Ladung",
|
||||
"weeklySummary": "Wöchentliche Zusammenfassung",
|
||||
"metric": "Kennzahl",
|
||||
"thisWeek": "Diese Woche",
|
||||
"change": "Änderung",
|
||||
|
|
@ -142,11 +151,12 @@
|
|||
"gridExport": "Netzeinspeisung",
|
||||
"batteryInOut": "Batterie Laden / Entladen",
|
||||
"dailyBreakdown": "Tägliche Aufschlüsselung",
|
||||
"prevWeek": "(Vorwoche)",
|
||||
"sendReport": "Bericht senden",
|
||||
"generatingReport": "Wochenbericht wird erstellt...",
|
||||
"reportSentTo": "Bericht gesendet an {email}",
|
||||
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
|
||||
"refreshReport": "Bericht aktualisieren",
|
||||
"refreshing": "Aktualisierung...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
|
||||
"proceed": "Fortfahren",
|
||||
|
|
@ -165,7 +175,7 @@
|
|||
"dailySummary": "Tagesübersicht",
|
||||
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
|
||||
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
|
||||
"currentWeekHint": "Aktuelle Woche (Mo–gestern)",
|
||||
"currentWeekHint": "Letzte 7 Tage",
|
||||
"intradayChart": "Tagesverlauf Energiefluss",
|
||||
"batteryPower": "Batterieleistung",
|
||||
"batterySoCLabel": "Batterie SoC",
|
||||
|
|
@ -179,21 +189,20 @@
|
|||
"yearlyReportTitle": "Jährlicher Leistungsbericht",
|
||||
"monthlyInsights": "Monatliche Einblicke",
|
||||
"yearlyInsights": "Jährliche Einblicke",
|
||||
"monthlySavings": "Ihre Einsparungen diesen Monat",
|
||||
"yearlySavings": "Ihre Einsparungen dieses Jahr",
|
||||
"monthlySummary": "Monatliche Zusammenfassung",
|
||||
"yearlySummary": "Jährliche Zusammenfassung",
|
||||
"total": "Gesamt",
|
||||
"weeksAggregated": "{count} Wochen aggregiert",
|
||||
"monthsAggregated": "{count} Monate aggregiert",
|
||||
"noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.",
|
||||
"noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.",
|
||||
"availableForGeneration": "Zur Generierung verfügbar",
|
||||
"generateMonth": "{month} {year} generieren ({count} Wochen)",
|
||||
"generateYear": "{year} generieren ({count} Monate)",
|
||||
"regenerateReport": "Neu generieren",
|
||||
"generatingMonthly": "Wird generiert...",
|
||||
"generatingYearly": "Wird generiert...",
|
||||
"reportInProgress": "{month} (in Bearbeitung)",
|
||||
"daysOfTotal": "{available} von {total} Tagen",
|
||||
"monthsOfTotal": "{available} von {total} Monaten",
|
||||
"monthlyAutoNote": "Der endgültige Bericht wird automatisch am 1. des nächsten Monats erstellt.",
|
||||
"yearlyAutoNote": "Der endgültige Bericht wird automatisch am 2. Januar erstellt.",
|
||||
"autoSendReports": "Berichte automatisch senden:",
|
||||
"autoSendSaved": "Automatische Versandeinstellungen gespeichert.",
|
||||
"autoSendSaveFailed": "Fehler beim Speichern der automatischen Versandeinstellungen.",
|
||||
"autoSendNoEmail": "E-Mail-Adresse im Reiter Information eingeben, um den automatischen Versand zu aktivieren",
|
||||
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
||||
"recentWeeklyReports": "Letzte Wochenberichte",
|
||||
"ai_analyzing": "KI analysiert...",
|
||||
|
|
@ -204,7 +213,7 @@
|
|||
"demo_test_button": "KI-Diagnose",
|
||||
"demo_hide_button": "KI-Diagnose ausblenden",
|
||||
"demo_panel_title": "KI-Diagnose",
|
||||
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
|
||||
"demo_custom_group": "Benutzerdefiniert (kann KI verwenden)",
|
||||
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
|
||||
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
|
||||
"demo_diagnose_button": "Diagnostizieren",
|
||||
|
|
@ -545,6 +554,7 @@
|
|||
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
|
||||
"tourHistoryTitle": "Verlauf",
|
||||
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.",
|
||||
"tourInstallationTicketsContent": "Support-Tickets für diese Installation anzeigen und verwalten — Probleme melden, Fortschritt verfolgen und KI-gestützte Diagnosen einsehen.",
|
||||
"tickets": "Tickets",
|
||||
"createTicket": "Ticket erstellen",
|
||||
"subject": "Betreff",
|
||||
|
|
@ -643,5 +653,38 @@
|
|||
"timelineAiDiagnosisCompletedDesc": "KI-Diagnose abgeschlossen.",
|
||||
"timelineAiDiagnosisFailedDesc": "KI-Diagnose fehlgeschlagen.",
|
||||
"timelineEscalatedDesc": "Ticket eskaliert.",
|
||||
"timelineResolutionAddedDesc": "Lösung hinzugefügt von {name}."
|
||||
"timelineResolutionAddedDesc": "Lösung hinzugefügt von {name}.",
|
||||
"terms_dialog_title": "Willkommen bei inesco energy",
|
||||
"terms_data_heading": "Ihre Daten",
|
||||
"terms_data_body": "Ihre Installationsdaten werden sicher in der Schweiz gespeichert. Wir geben Ihre Daten nicht an Dritte weiter.",
|
||||
"terms_ai_heading": "KI-gestützte Einblicke",
|
||||
"terms_ai_body": "Wir nutzen einen in der EU gehosteten KI-Dienst, um Diagnosen und Einblicke für Ihre Installationen bereitzustellen. KI-generierte Ergebnisse sind Empfehlungen und sollten von qualifiziertem Personal überprüft werden.",
|
||||
"terms_cookies_heading": "Browser-Speicher",
|
||||
"terms_cookies_body": "Diese Plattform speichert Anmelde- und Einstellungsdaten in Ihrem Browser, um Sie angemeldet zu halten und Ihre Sprachauswahl zu speichern.",
|
||||
"terms_acknowledge_button": "Ich verstehe",
|
||||
"privacy_menu_item": "Daten & Datenschutz",
|
||||
"privacy_dialog_title": "Daten & Datenschutz",
|
||||
"privacy_data_heading": "Wo werden meine Daten gespeichert?",
|
||||
"privacy_data_body": "Ihre Installationsdaten werden auf Servern in der Schweiz gespeichert. Nur autorisiertes Personal von inesco energy kann zu Supportzwecken auf Ihre Daten zugreifen.",
|
||||
"privacy_ai_heading": "Wie wird KI eingesetzt?",
|
||||
"privacy_ai_body": "Wir nutzen einen in der Europäischen Union gehosteten KI-Dienst, um Ihre Installationsdaten zu analysieren und diagnostische Einblicke zu liefern. Die KI verarbeitet technische Daten wie Batteriemesswerte und Fehlercodes. KI-Empfehlungen sollten stets von qualifiziertem Personal überprüft werden.",
|
||||
"privacy_browser_heading": "Was speichert mein Browser?",
|
||||
"privacy_browser_body": "Ihr Browser speichert Ihre Anmeldesitzung, um Sie angemeldet zu halten, sowie Ihre Sprach- und Designeinstellungen. Es werden keine Tracking- oder Werbe-Cookies verwendet.",
|
||||
"privacy_access_heading": "Wer hat Zugriff auf meine Daten?",
|
||||
"privacy_access_body": "Ihre Daten werden nicht an Dritte weitergegeben. Sie werden ausschliesslich für den Betrieb der Plattform und zur Bereitstellung von Einblicken in Ihre Installationen verwendet.",
|
||||
"privacy_close_button": "Schliessen",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Anzahl der Wechselrichter",
|
||||
"documentsTab": "Dokumente",
|
||||
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 MB.",
|
||||
"attachFiles": "Dateien anhängen",
|
||||
"attachments": "Anhänge",
|
||||
"documents": "Dokumente",
|
||||
"installationDocuments": "Installationsdokumente",
|
||||
"uploadDocument": "Dokument hochladen",
|
||||
"noDocuments": "Noch keine Dokumente.",
|
||||
"fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.",
|
||||
"invalidFileType": "Ungültiger Dateityp.",
|
||||
"uploadFailed": "Hochladen fehlgeschlagen.",
|
||||
"uploadSuccess": "Hochladen erfolgreich."
|
||||
}
|
||||
|
|
@ -2,7 +2,15 @@
|
|||
"allInstallations": "All installations",
|
||||
"applyChanges": "Apply changes",
|
||||
"country": "Country",
|
||||
"street": "Street",
|
||||
"postCode": "Postcode",
|
||||
"city": "City",
|
||||
"canton": "Canton",
|
||||
"distributionPartner": "Distribution Partner",
|
||||
"inverterFirmwareVersion": "Inverter Firmware Version",
|
||||
"batteryFirmwareVersion": "Battery Firmware Version",
|
||||
"networkProvider": "Network Provider",
|
||||
"emailAddress": "Email Address",
|
||||
"customerName": "Customer name",
|
||||
"english": "English",
|
||||
"german": "German",
|
||||
|
|
@ -62,6 +70,9 @@
|
|||
"emsOther": "Other",
|
||||
"generalInfo": "General Info",
|
||||
"installationSetup": "Installation Setup",
|
||||
"couplingType": "AC/DC Coupling",
|
||||
"couplingAC": "AC-coupled",
|
||||
"couplingDC": "DC-coupled",
|
||||
"selectModel": "Select model...",
|
||||
"inverterN": "Inverter {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -91,6 +102,7 @@
|
|||
"deleteFolder": "Delete Folder",
|
||||
"grantAccessToFolders": "Grant Access to Folders",
|
||||
"grantAccessToInstallations": "Grant Access to Installations",
|
||||
"searchInstallations": "Search installations...",
|
||||
"cannotloadloggingdata": "Cannot load logging data",
|
||||
"grantedAccessToUsers": "Granted access to users: ",
|
||||
"unableToGrantAccess": "Unable to grant access to: ",
|
||||
|
|
@ -103,7 +115,6 @@
|
|||
"reportTitle": "Weekly Performance Report",
|
||||
"weeklyInsights": "Weekly Insights",
|
||||
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
|
||||
"weeklySavings": "Your Savings This Week",
|
||||
"solarEnergyUsed": "Energy Saved",
|
||||
"solarStayedHome": "solar + battery, not bought from grid",
|
||||
"daysOfYourUsage": "days of your usage",
|
||||
|
|
@ -111,10 +122,8 @@
|
|||
"atCHFRate": "at 0.39 CHF/kWh avg.",
|
||||
"solarCoverage": "Energy Independence",
|
||||
"fromSolarSub": "from your own solar + battery system",
|
||||
"avgDailyConsumption": "Avg Daily Consumption",
|
||||
"batteryEfficiency": "Battery Efficiency",
|
||||
"batteryEffSub": "discharge vs charge",
|
||||
"weeklySummary": "Weekly Summary",
|
||||
"metric": "Metric",
|
||||
"thisWeek": "This Week",
|
||||
"change": "Change",
|
||||
|
|
@ -124,11 +133,12 @@
|
|||
"gridExport": "Grid Export",
|
||||
"batteryInOut": "Battery Charge / Discharge",
|
||||
"dailyBreakdown": "Daily Breakdown",
|
||||
"prevWeek": "(prev week)",
|
||||
"sendReport": "Send Report",
|
||||
"generatingReport": "Generating weekly report...",
|
||||
"reportSentTo": "Report sent to {email}",
|
||||
"reportSendError": "Failed to send. Please check the email address and try again.",
|
||||
"refreshReport": "Refresh Report",
|
||||
"refreshing": "Refreshing...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Granted access to user {name}",
|
||||
"proceed": "Proceed",
|
||||
|
|
@ -147,7 +157,7 @@
|
|||
"dailySummary": "Daily Summary",
|
||||
"noDataForDate": "No data available for the selected date.",
|
||||
"noHourlyData": "Hourly data not available for this day.",
|
||||
"currentWeekHint": "Current week (Mon–yesterday)",
|
||||
"currentWeekHint": "Last 7 days",
|
||||
"intradayChart": "Intraday Power Flow",
|
||||
"batteryPower": "Battery Power",
|
||||
"batterySoCLabel": "Battery SoC",
|
||||
|
|
@ -161,21 +171,20 @@
|
|||
"yearlyReportTitle": "Annual Performance Report",
|
||||
"monthlyInsights": "Monthly Insights",
|
||||
"yearlyInsights": "Annual Insights",
|
||||
"monthlySavings": "Your Savings This Month",
|
||||
"yearlySavings": "Your Savings This Year",
|
||||
"monthlySummary": "Monthly Summary",
|
||||
"yearlySummary": "Annual Summary",
|
||||
"total": "Total",
|
||||
"weeksAggregated": "{count} weeks aggregated",
|
||||
"monthsAggregated": "{count} months aggregated",
|
||||
"noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.",
|
||||
"noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.",
|
||||
"availableForGeneration": "Available for Generation",
|
||||
"generateMonth": "Generate {month} {year} ({count} weeks)",
|
||||
"generateYear": "Generate {year} ({count} months)",
|
||||
"regenerateReport": "Regenerate",
|
||||
"generatingMonthly": "Generating...",
|
||||
"generatingYearly": "Generating...",
|
||||
"reportInProgress": "{month} (in progress)",
|
||||
"daysOfTotal": "{available} of {total} days",
|
||||
"monthsOfTotal": "{available} of {total} months",
|
||||
"monthlyAutoNote": "Final report will be automatically generated on the 1st of next month.",
|
||||
"yearlyAutoNote": "Final report will be automatically generated on January 2nd.",
|
||||
"autoSendReports": "Auto-send reports:",
|
||||
"autoSendSaved": "Auto-send preferences saved.",
|
||||
"autoSendSaveFailed": "Failed to save auto-send preferences.",
|
||||
"autoSendNoEmail": "Set email address in Information tab to enable auto-send",
|
||||
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
||||
"recentWeeklyReports": "Recent Weekly Reports",
|
||||
"ai_analyzing": "AI is analyzing...",
|
||||
|
|
@ -186,7 +195,7 @@
|
|||
"demo_test_button": "AI Diagnosis",
|
||||
"demo_hide_button": "Hide AI Diagnosis",
|
||||
"demo_panel_title": "AI Diagnosis",
|
||||
"demo_custom_group": "Custom (may use Mistral AI)",
|
||||
"demo_custom_group": "Custom (may use AI)",
|
||||
"demo_custom_option": "Type custom alarm below…",
|
||||
"demo_custom_placeholder": "e.g. UnknownBatteryFault",
|
||||
"demo_diagnose_button": "Diagnose",
|
||||
|
|
@ -293,6 +302,7 @@
|
|||
"tourConfigurationContent": "View and modify device settings for this installation.",
|
||||
"tourHistoryTitle": "History",
|
||||
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.",
|
||||
"tourInstallationTicketsContent": "View and manage support tickets for this installation — report issues, track progress, and see AI-powered diagnostics.",
|
||||
"tickets": "Tickets",
|
||||
"createTicket": "Create Ticket",
|
||||
"subject": "Subject",
|
||||
|
|
@ -391,5 +401,38 @@
|
|||
"timelineAiDiagnosisCompletedDesc": "AI diagnosis completed.",
|
||||
"timelineAiDiagnosisFailedDesc": "AI diagnosis failed.",
|
||||
"timelineEscalatedDesc": "Ticket escalated.",
|
||||
"timelineResolutionAddedDesc": "Resolution added by {name}."
|
||||
"timelineResolutionAddedDesc": "Resolution added by {name}.",
|
||||
"terms_dialog_title": "Welcome to inesco energy",
|
||||
"terms_data_heading": "Your Data",
|
||||
"terms_data_body": "Your installation data is securely stored in Switzerland. We do not share your data with third parties.",
|
||||
"terms_ai_heading": "AI-Powered Insights",
|
||||
"terms_ai_body": "We use an AI service hosted in the EU to provide diagnostics and insights for your installations. AI-generated results are recommendations and should be verified by qualified personnel.",
|
||||
"terms_cookies_heading": "Browser Storage",
|
||||
"terms_cookies_body": "This platform stores login and preference settings in your browser to keep you signed in and remember your language choice.",
|
||||
"terms_acknowledge_button": "I understand",
|
||||
"privacy_menu_item": "Data & Privacy",
|
||||
"privacy_dialog_title": "Data & Privacy",
|
||||
"privacy_data_heading": "Where is my data stored?",
|
||||
"privacy_data_body": "Your installation data is stored on servers in Switzerland. Only authorized inesco energy personnel can access your data for support purposes.",
|
||||
"privacy_ai_heading": "How is AI used?",
|
||||
"privacy_ai_body": "We use an AI service hosted in the European Union to analyze your installation data and provide diagnostic insights. The AI processes technical data such as battery readings and error codes. AI recommendations should always be verified by qualified personnel.",
|
||||
"privacy_browser_heading": "What does my browser store?",
|
||||
"privacy_browser_body": "Your browser stores your login session to keep you signed in, and your language and theme preferences. No tracking or advertising cookies are used.",
|
||||
"privacy_access_heading": "Who has access to my data?",
|
||||
"privacy_access_body": "Your data is not shared with third parties. It is used solely to operate the platform and provide you with insights about your installations.",
|
||||
"privacy_close_button": "Close",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Number of Inverters",
|
||||
"documentsTab": "Documents",
|
||||
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB.",
|
||||
"attachFiles": "Attach Files",
|
||||
"attachments": "Attachments",
|
||||
"documents": "Documents",
|
||||
"installationDocuments": "Installation Documents",
|
||||
"uploadDocument": "Upload Document",
|
||||
"noDocuments": "No documents yet.",
|
||||
"fileTooLarge": "File exceeds maximum size of 25 MB.",
|
||||
"invalidFileType": "Invalid file type.",
|
||||
"uploadFailed": "Upload failed.",
|
||||
"uploadSuccess": "Upload successful."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@
|
|||
"alarms": "Alarmes",
|
||||
"applyChanges": "Appliquer",
|
||||
"country": "Pays",
|
||||
"street": "Rue",
|
||||
"postCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"canton": "Canton",
|
||||
"distributionPartner": "Partenaire de distribution",
|
||||
"inverterFirmwareVersion": "Version firmware onduleur",
|
||||
"batteryFirmwareVersion": "Version firmware batterie",
|
||||
"networkProvider": "Gestionnaire de réseau",
|
||||
"emailAddress": "Adresse e-mail",
|
||||
"createNewFolder": "Nouveau dossier",
|
||||
"createNewUser": "Nouvel utilisateur",
|
||||
"customerName": "Nom du client",
|
||||
|
|
@ -74,6 +82,9 @@
|
|||
"emsOther": "Autre",
|
||||
"generalInfo": "Informations générales",
|
||||
"installationSetup": "Configuration de l'installation",
|
||||
"couplingType": "Couplage AC/DC",
|
||||
"couplingAC": "Couplage AC",
|
||||
"couplingDC": "Couplage DC",
|
||||
"selectModel": "Sélectionner le modèle...",
|
||||
"inverterN": "Onduleur {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -103,6 +114,7 @@
|
|||
"deleteFolder": "Supprimer le dossier",
|
||||
"grantAccessToFolders": "Accorder l'accès aux dossiers",
|
||||
"grantAccessToInstallations": "Accorder l'accès aux installations",
|
||||
"searchInstallations": "Rechercher des installations...",
|
||||
"cannotloadloggingdata": "Impossible de charger les données de journalisation",
|
||||
"grantedAccessToUsers": "Accès accordé aux utilisateurs",
|
||||
"unableToGrantAccess": "Impossible d'accorder l'accès à",
|
||||
|
|
@ -115,7 +127,6 @@
|
|||
"reportTitle": "Rapport de performance hebdomadaire",
|
||||
"weeklyInsights": "Aperçus hebdomadaires",
|
||||
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
|
||||
"weeklySavings": "Vos économies cette semaine",
|
||||
"solarEnergyUsed": "Énergie économisée",
|
||||
"solarStayedHome": "solaire + batterie, non achetée au réseau",
|
||||
"daysOfYourUsage": "jours de votre consommation",
|
||||
|
|
@ -123,10 +134,8 @@
|
|||
"atCHFRate": "à 0,39 CHF/kWh moy.",
|
||||
"solarCoverage": "Indépendance énergétique",
|
||||
"fromSolarSub": "de votre système solaire + batterie",
|
||||
"avgDailyConsumption": "Conso. quotidienne moy.",
|
||||
"batteryEfficiency": "Efficacité de la batterie",
|
||||
"batteryEffSub": "décharge vs charge",
|
||||
"weeklySummary": "Résumé hebdomadaire",
|
||||
"metric": "Métrique",
|
||||
"thisWeek": "Cette semaine",
|
||||
"change": "Variation",
|
||||
|
|
@ -136,11 +145,12 @@
|
|||
"gridExport": "Exportation réseau",
|
||||
"batteryInOut": "Batterie Charge / Décharge",
|
||||
"dailyBreakdown": "Répartition quotidienne",
|
||||
"prevWeek": "(semaine précédente)",
|
||||
"sendReport": "Envoyer le rapport",
|
||||
"generatingReport": "Génération du rapport hebdomadaire...",
|
||||
"reportSentTo": "Rapport envoyé à {email}",
|
||||
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
|
||||
"refreshReport": "Actualiser le rapport",
|
||||
"refreshing": "Actualisation...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
|
||||
"proceed": "Continuer",
|
||||
|
|
@ -159,7 +169,7 @@
|
|||
"dailySummary": "Résumé du jour",
|
||||
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
|
||||
"noHourlyData": "Données horaires non disponibles pour ce jour.",
|
||||
"currentWeekHint": "Semaine en cours (lun–hier)",
|
||||
"currentWeekHint": "7 derniers jours",
|
||||
"intradayChart": "Flux d'énergie journalier",
|
||||
"batteryPower": "Puissance batterie",
|
||||
"batterySoCLabel": "SoC batterie",
|
||||
|
|
@ -173,21 +183,20 @@
|
|||
"yearlyReportTitle": "Rapport de performance annuel",
|
||||
"monthlyInsights": "Aperçus mensuels",
|
||||
"yearlyInsights": "Aperçus annuels",
|
||||
"monthlySavings": "Vos économies ce mois",
|
||||
"yearlySavings": "Vos économies cette année",
|
||||
"monthlySummary": "Résumé mensuel",
|
||||
"yearlySummary": "Résumé annuel",
|
||||
"total": "Total",
|
||||
"weeksAggregated": "{count} semaines agrégées",
|
||||
"monthsAggregated": "{count} mois agrégés",
|
||||
"noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.",
|
||||
"noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.",
|
||||
"availableForGeneration": "Disponible pour génération",
|
||||
"generateMonth": "Générer {month} {year} ({count} semaines)",
|
||||
"generateYear": "Générer {year} ({count} mois)",
|
||||
"regenerateReport": "Régénérer",
|
||||
"generatingMonthly": "Génération en cours...",
|
||||
"generatingYearly": "Génération en cours...",
|
||||
"reportInProgress": "{month} (en cours)",
|
||||
"daysOfTotal": "{available} sur {total} jours",
|
||||
"monthsOfTotal": "{available} sur {total} mois",
|
||||
"monthlyAutoNote": "Le rapport final sera généré automatiquement le 1er du mois prochain.",
|
||||
"yearlyAutoNote": "Le rapport final sera généré automatiquement le 2 janvier.",
|
||||
"autoSendReports": "Envoi automatique des rapports :",
|
||||
"autoSendSaved": "Préférences d'envoi automatique enregistrées.",
|
||||
"autoSendSaveFailed": "Échec de l'enregistrement des préférences d'envoi automatique.",
|
||||
"autoSendNoEmail": "Définir l'adresse e-mail dans l'onglet Information pour activer l'envoi automatique",
|
||||
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
||||
"recentWeeklyReports": "Derniers rapports hebdomadaires",
|
||||
"ai_analyzing": "L'IA analyse...",
|
||||
|
|
@ -198,7 +207,7 @@
|
|||
"demo_test_button": "Diagnostic IA",
|
||||
"demo_hide_button": "Masquer le diagnostic IA",
|
||||
"demo_panel_title": "Diagnostic IA",
|
||||
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
|
||||
"demo_custom_group": "Personnalisé (peut utiliser IA)",
|
||||
"demo_custom_option": "Saisir une alarme personnalisée…",
|
||||
"demo_custom_placeholder": "ex. UnknownBatteryFault",
|
||||
"demo_diagnose_button": "Diagnostiquer",
|
||||
|
|
@ -545,6 +554,7 @@
|
|||
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
|
||||
"tourHistoryTitle": "Historique",
|
||||
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.",
|
||||
"tourInstallationTicketsContent": "Consultez et gérez les tickets de support pour cette installation — signalez des problèmes, suivez la progression et consultez les diagnostics IA.",
|
||||
"tickets": "Tickets",
|
||||
"createTicket": "Créer un ticket",
|
||||
"subject": "Objet",
|
||||
|
|
@ -643,5 +653,38 @@
|
|||
"timelineAiDiagnosisCompletedDesc": "Diagnostic IA terminé.",
|
||||
"timelineAiDiagnosisFailedDesc": "Diagnostic IA échoué.",
|
||||
"timelineEscalatedDesc": "Ticket escaladé.",
|
||||
"timelineResolutionAddedDesc": "Résolution ajoutée par {name}."
|
||||
"timelineResolutionAddedDesc": "Résolution ajoutée par {name}.",
|
||||
"terms_dialog_title": "Bienvenue chez inesco energy",
|
||||
"terms_data_heading": "Vos données",
|
||||
"terms_data_body": "Les données de votre installation sont stockées en toute sécurité en Suisse. Nous ne partageons pas vos données avec des tiers.",
|
||||
"terms_ai_heading": "Analyses basées sur l'IA",
|
||||
"terms_ai_body": "Nous utilisons un service d'IA hébergé dans l'UE pour fournir des diagnostics et des analyses pour vos installations. Les résultats générés par l'IA sont des recommandations et doivent être vérifiés par du personnel qualifié.",
|
||||
"terms_cookies_heading": "Stockage du navigateur",
|
||||
"terms_cookies_body": "Cette plateforme enregistre vos paramètres de connexion et de préférences dans votre navigateur pour maintenir votre session et mémoriser votre choix de langue.",
|
||||
"terms_acknowledge_button": "Je comprends",
|
||||
"privacy_menu_item": "Données et confidentialité",
|
||||
"privacy_dialog_title": "Données et confidentialité",
|
||||
"privacy_data_heading": "Où sont stockées mes données ?",
|
||||
"privacy_data_body": "Les données de votre installation sont stockées sur des serveurs en Suisse. Seul le personnel autorisé d'inesco energy peut accéder à vos données à des fins d'assistance.",
|
||||
"privacy_ai_heading": "Comment l'IA est-elle utilisée ?",
|
||||
"privacy_ai_body": "Nous utilisons un service d'IA hébergé dans l'Union européenne pour analyser les données de votre installation et fournir des informations diagnostiques. L'IA traite des données techniques telles que les relevés de batterie et les codes d'erreur. Les recommandations de l'IA doivent toujours être vérifiées par du personnel qualifié.",
|
||||
"privacy_browser_heading": "Que stocke mon navigateur ?",
|
||||
"privacy_browser_body": "Votre navigateur stocke votre session de connexion pour vous maintenir connecté, ainsi que vos préférences de langue et de thème. Aucun cookie de suivi ou publicitaire n'est utilisé.",
|
||||
"privacy_access_heading": "Qui a accès à mes données ?",
|
||||
"privacy_access_body": "Vos données ne sont pas partagées avec des tiers. Elles sont utilisées uniquement pour le fonctionnement de la plateforme et pour vous fournir des informations sur vos installations.",
|
||||
"privacy_close_button": "Fermer",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Nombre d'onduleurs",
|
||||
"documentsTab": "Documents",
|
||||
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 Mo.",
|
||||
"attachFiles": "Joindre des fichiers",
|
||||
"attachments": "Pièces jointes",
|
||||
"documents": "Documents",
|
||||
"installationDocuments": "Documents d'installation",
|
||||
"uploadDocument": "Télécharger un document",
|
||||
"noDocuments": "Aucun document pour le moment.",
|
||||
"fileTooLarge": "Le fichier dépasse la taille maximale de 25 Mo.",
|
||||
"invalidFileType": "Type de fichier non valide.",
|
||||
"uploadFailed": "Échec du téléchargement.",
|
||||
"uploadSuccess": "Téléchargement réussi."
|
||||
}
|
||||
|
|
@ -2,7 +2,15 @@
|
|||
"allInstallations": "Tutte le installazioni",
|
||||
"applyChanges": "Applica modifiche",
|
||||
"country": "Paese",
|
||||
"street": "Via",
|
||||
"postCode": "CAP",
|
||||
"city": "Città",
|
||||
"canton": "Cantone",
|
||||
"distributionPartner": "Partner di distribuzione",
|
||||
"inverterFirmwareVersion": "Versione firmware inverter",
|
||||
"batteryFirmwareVersion": "Versione firmware batteria",
|
||||
"networkProvider": "Gestore di rete",
|
||||
"emailAddress": "Indirizzo e-mail",
|
||||
"customerName": "Nome cliente",
|
||||
"english": "Inglese",
|
||||
"german": "Tedesco",
|
||||
|
|
@ -62,6 +70,9 @@
|
|||
"emsOther": "Altro",
|
||||
"generalInfo": "Informazioni generali",
|
||||
"installationSetup": "Configurazione installazione",
|
||||
"couplingType": "Accoppiamento AC/DC",
|
||||
"couplingAC": "Accoppiamento AC",
|
||||
"couplingDC": "Accoppiamento DC",
|
||||
"selectModel": "Seleziona modello...",
|
||||
"inverterN": "Inverter {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -91,6 +102,7 @@
|
|||
"deleteFolder": "Elimina cartella",
|
||||
"grantAccessToFolders": "Concedi accesso alle cartelle",
|
||||
"grantAccessToInstallations": "Concedi accesso alle installazioni",
|
||||
"searchInstallations": "Cerca installazioni...",
|
||||
"cannotloadloggingdata": "Impossibile caricare i dati di registro",
|
||||
"grantedAccessToUsers": "Accesso concesso agli utenti: ",
|
||||
"unableToGrantAccess": "Impossibile concedere l'accesso a: ",
|
||||
|
|
@ -126,7 +138,6 @@
|
|||
"reportTitle": "Rapporto settimanale sulle prestazioni",
|
||||
"weeklyInsights": "Approfondimenti settimanali",
|
||||
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
|
||||
"weeklySavings": "I tuoi risparmi questa settimana",
|
||||
"solarEnergyUsed": "Energia risparmiata",
|
||||
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
|
||||
"daysOfYourUsage": "giorni del tuo consumo",
|
||||
|
|
@ -134,10 +145,8 @@
|
|||
"atCHFRate": "a 0,39 CHF/kWh media",
|
||||
"solarCoverage": "Indipendenza energetica",
|
||||
"fromSolarSub": "dal proprio impianto solare + batteria",
|
||||
"avgDailyConsumption": "Consumo medio giornaliero",
|
||||
"batteryEfficiency": "Efficienza della batteria",
|
||||
"batteryEffSub": "scarica vs carica",
|
||||
"weeklySummary": "Riepilogo settimanale",
|
||||
"metric": "Metrica",
|
||||
"thisWeek": "Questa settimana",
|
||||
"change": "Variazione",
|
||||
|
|
@ -147,11 +156,12 @@
|
|||
"gridExport": "Esportazione rete",
|
||||
"batteryInOut": "Batteria Carica / Scarica",
|
||||
"dailyBreakdown": "Ripartizione giornaliera",
|
||||
"prevWeek": "(settimana precedente)",
|
||||
"sendReport": "Invia rapporto",
|
||||
"generatingReport": "Generazione del rapporto settimanale...",
|
||||
"reportSentTo": "Rapporto inviato a {email}",
|
||||
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
|
||||
"refreshReport": "Aggiorna rapporto",
|
||||
"refreshing": "Aggiornamento...",
|
||||
"ok": "Ok",
|
||||
"grantedAccessToUser": "Accesso concesso all'utente {name}",
|
||||
"proceed": "Procedi",
|
||||
|
|
@ -170,7 +180,7 @@
|
|||
"dailySummary": "Riepilogo del giorno",
|
||||
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
|
||||
"noHourlyData": "Dati orari non disponibili per questo giorno.",
|
||||
"currentWeekHint": "Settimana corrente (lun–ieri)",
|
||||
"currentWeekHint": "Ultimi 7 giorni",
|
||||
"intradayChart": "Flusso energetico giornaliero",
|
||||
"batteryPower": "Potenza batteria",
|
||||
"batterySoCLabel": "SoC batteria",
|
||||
|
|
@ -184,21 +194,20 @@
|
|||
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
|
||||
"monthlyInsights": "Approfondimenti mensili",
|
||||
"yearlyInsights": "Approfondimenti annuali",
|
||||
"monthlySavings": "I tuoi risparmi questo mese",
|
||||
"yearlySavings": "I tuoi risparmi quest'anno",
|
||||
"monthlySummary": "Riepilogo mensile",
|
||||
"yearlySummary": "Riepilogo annuale",
|
||||
"total": "Totale",
|
||||
"weeksAggregated": "{count} settimane aggregate",
|
||||
"monthsAggregated": "{count} mesi aggregati",
|
||||
"noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.",
|
||||
"noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.",
|
||||
"availableForGeneration": "Disponibile per la generazione",
|
||||
"generateMonth": "Genera {month} {year} ({count} settimane)",
|
||||
"generateYear": "Genera {year} ({count} mesi)",
|
||||
"regenerateReport": "Rigenera",
|
||||
"generatingMonthly": "Generazione in corso...",
|
||||
"generatingYearly": "Generazione in corso...",
|
||||
"reportInProgress": "{month} (in corso)",
|
||||
"daysOfTotal": "{available} di {total} giorni",
|
||||
"monthsOfTotal": "{available} di {total} mesi",
|
||||
"monthlyAutoNote": "Il rapporto finale verrà generato automaticamente il 1° del mese prossimo.",
|
||||
"yearlyAutoNote": "Il rapporto finale verrà generato automaticamente il 2 gennaio.",
|
||||
"autoSendReports": "Invio automatico rapporti:",
|
||||
"autoSendSaved": "Preferenze di invio automatico salvate.",
|
||||
"autoSendSaveFailed": "Impossibile salvare le preferenze di invio automatico.",
|
||||
"autoSendNoEmail": "Impostare l'indirizzo e-mail nella scheda Informazioni per attivare l'invio automatico",
|
||||
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
||||
"recentWeeklyReports": "Ultimi rapporti settimanali",
|
||||
"ai_analyzing": "L'IA sta analizzando...",
|
||||
|
|
@ -209,7 +218,7 @@
|
|||
"demo_test_button": "Diagnosi IA",
|
||||
"demo_hide_button": "Nascondi diagnosi IA",
|
||||
"demo_panel_title": "Diagnosi IA",
|
||||
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
|
||||
"demo_custom_group": "Personalizzato (potrebbe usare IA)",
|
||||
"demo_custom_option": "Inserisci allarme personalizzato…",
|
||||
"demo_custom_placeholder": "es. UnknownBatteryFault",
|
||||
"demo_diagnose_button": "Diagnostica",
|
||||
|
|
@ -545,6 +554,7 @@
|
|||
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
|
||||
"tourHistoryTitle": "Cronologia",
|
||||
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.",
|
||||
"tourInstallationTicketsContent": "Visualizza e gestisci i ticket di supporto per questa installazione — segnala problemi, monitora i progressi e consulta le diagnosi IA.",
|
||||
"tickets": "Ticket",
|
||||
"createTicket": "Crea ticket",
|
||||
"subject": "Oggetto",
|
||||
|
|
@ -643,5 +653,38 @@
|
|||
"timelineAiDiagnosisCompletedDesc": "Diagnosi IA completata.",
|
||||
"timelineAiDiagnosisFailedDesc": "Diagnosi IA fallita.",
|
||||
"timelineEscalatedDesc": "Ticket escalato.",
|
||||
"timelineResolutionAddedDesc": "Risoluzione aggiunta da {name}."
|
||||
"timelineResolutionAddedDesc": "Risoluzione aggiunta da {name}.",
|
||||
"terms_dialog_title": "Benvenuto su inesco energy",
|
||||
"terms_data_heading": "I tuoi dati",
|
||||
"terms_data_body": "I dati della tua installazione sono archiviati in modo sicuro in Svizzera. Non condividiamo i tuoi dati con terze parti.",
|
||||
"terms_ai_heading": "Analisi basate sull'IA",
|
||||
"terms_ai_body": "Utilizziamo un servizio di IA ospitato nell'UE per fornire diagnostica e analisi per le tue installazioni. I risultati generati dall'IA sono raccomandazioni e devono essere verificati da personale qualificato.",
|
||||
"terms_cookies_heading": "Archiviazione del browser",
|
||||
"terms_cookies_body": "Questa piattaforma memorizza le impostazioni di accesso e le preferenze nel browser per mantenerti connesso e ricordare la tua scelta linguistica.",
|
||||
"terms_acknowledge_button": "Ho capito",
|
||||
"privacy_menu_item": "Dati e privacy",
|
||||
"privacy_dialog_title": "Dati e privacy",
|
||||
"privacy_data_heading": "Dove vengono archiviati i miei dati?",
|
||||
"privacy_data_body": "I dati della tua installazione sono archiviati su server in Svizzera. Solo il personale autorizzato di inesco energy può accedere ai tuoi dati per scopi di assistenza.",
|
||||
"privacy_ai_heading": "Come viene utilizzata l'IA?",
|
||||
"privacy_ai_body": "Utilizziamo un servizio di IA ospitato nell'Unione Europea per analizzare i dati della tua installazione e fornire informazioni diagnostiche. L'IA elabora dati tecnici come le letture delle batterie e i codici di errore. Le raccomandazioni dell'IA devono sempre essere verificate da personale qualificato.",
|
||||
"privacy_browser_heading": "Cosa memorizza il mio browser?",
|
||||
"privacy_browser_body": "Il tuo browser memorizza la sessione di accesso per mantenerti connesso e le tue preferenze di lingua e tema. Non vengono utilizzati cookie di tracciamento o pubblicitari.",
|
||||
"privacy_access_heading": "Chi ha accesso ai miei dati?",
|
||||
"privacy_access_body": "I tuoi dati non vengono condivisi con terze parti. Vengono utilizzati esclusivamente per il funzionamento della piattaforma e per fornirti informazioni sulle tue installazioni.",
|
||||
"privacy_close_button": "Chiudi",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Numero di inverter",
|
||||
"documentsTab": "Documenti",
|
||||
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 MB.",
|
||||
"attachFiles": "Allega file",
|
||||
"attachments": "Allegati",
|
||||
"documents": "Documenti",
|
||||
"installationDocuments": "Documenti dell'installazione",
|
||||
"uploadDocument": "Carica documento",
|
||||
"noDocuments": "Nessun documento ancora.",
|
||||
"fileTooLarge": "Il file supera la dimensione massima di 25 MB.",
|
||||
"invalidFileType": "Tipo di file non valido.",
|
||||
"uploadFailed": "Caricamento fallito.",
|
||||
"uploadSuccess": "Caricamento riuscito."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import {
|
|||
import React, { useContext, useRef, useState } from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ExpandMoreTwoToneIcon from '@mui/icons-material/ExpandMoreTwoTone';
|
||||
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
|
||||
import { ThemeContext } from '../../../../theme/ThemeProvider';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import '../../../../App.css';
|
||||
import DataPrivacyDialog from '../../../../components/DataPrivacyDialog';
|
||||
|
||||
interface HeaderButtonsProps {
|
||||
language: string;
|
||||
|
|
@ -79,6 +81,7 @@ function HeaderMenu(props: HeaderButtonsProps) {
|
|||
const setThemeName = themeContext;
|
||||
|
||||
const [darkState, setDarkState] = useState(false);
|
||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||
|
||||
const handleThemeChange = () => {
|
||||
setDarkState(!darkState);
|
||||
|
|
@ -132,6 +135,20 @@ function HeaderMenu(props: HeaderButtonsProps) {
|
|||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
classes={{ root: 'MuiListItem-indicators' }}
|
||||
onClick={() => setPrivacyOpen(true)}
|
||||
>
|
||||
<ListItemText
|
||||
primaryTypographyProps={{ noWrap: true }}
|
||||
primary={
|
||||
<Box display="flex" alignItems="center">
|
||||
<ShieldOutlinedIcon fontSize="small" sx={{ mr: 0.5 }} />
|
||||
<FormattedMessage id="privacy_menu_item" defaultMessage="Data & Privacy" />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</ListWrapper>
|
||||
<div
|
||||
|
|
@ -152,6 +169,10 @@ function HeaderMenu(props: HeaderButtonsProps) {
|
|||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
<DataPrivacyDialog
|
||||
open={privacyOpen}
|
||||
onClose={() => setPrivacyOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ function SidebarMenu() {
|
|||
accessToSodistore,
|
||||
accessToSalidomo,
|
||||
accessToSodiohome,
|
||||
accessToSodistoreGrid
|
||||
accessToSodistoreGrid,
|
||||
accessToSodistorePro
|
||||
} = useContext(ProductIdContext);
|
||||
|
||||
return (
|
||||
|
|
@ -185,37 +186,20 @@ function SidebarMenu() {
|
|||
}
|
||||
>
|
||||
<SubMenuWrapper>
|
||||
{accessToSalimax && (
|
||||
{accessToSodiohome && (
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/installations"
|
||||
startIcon={<BrightnessLowTwoToneIcon />}
|
||||
>
|
||||
<Box sx={{ marginTop: '3px' }}>
|
||||
<FormattedMessage id="salimax" defaultMessage="Salimax" />
|
||||
</Box>
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
{accessToSodistore && (
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/sodistore_installations"
|
||||
to="/sodiohome_installations"
|
||||
startIcon={<BrightnessLowTwoToneIcon />}
|
||||
>
|
||||
<Box sx={{ marginTop: '3px' }}>
|
||||
<FormattedMessage
|
||||
id="sodistore"
|
||||
defaultMessage="Sodistore Max"
|
||||
id="sodiohome"
|
||||
defaultMessage="Sodistore Home"
|
||||
/>
|
||||
</Box>
|
||||
</Button>
|
||||
|
|
@ -223,20 +207,20 @@ function SidebarMenu() {
|
|||
</List>
|
||||
)}
|
||||
|
||||
{accessToSalidomo && (
|
||||
{accessToSodistorePro && (
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/salidomo_installations"
|
||||
to="/sodistorepro_installations"
|
||||
startIcon={<BrightnessLowTwoToneIcon />}
|
||||
>
|
||||
<Box sx={{ marginTop: '3px' }}>
|
||||
<FormattedMessage
|
||||
id="salidomo"
|
||||
defaultMessage="Salidomo"
|
||||
id="sodistorepro"
|
||||
defaultMessage="Sodistore Pro"
|
||||
/>
|
||||
</Box>
|
||||
</Button>
|
||||
|
|
@ -265,20 +249,59 @@ function SidebarMenu() {
|
|||
</List>
|
||||
)}
|
||||
|
||||
{accessToSodiohome && (
|
||||
{accessToSodistore && (
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/sodiohome_installations"
|
||||
to="/sodistore_installations"
|
||||
startIcon={<BrightnessLowTwoToneIcon />}
|
||||
>
|
||||
<Box sx={{ marginTop: '3px' }}>
|
||||
<FormattedMessage
|
||||
id="sodiohome"
|
||||
defaultMessage="Sodistore Home"
|
||||
id="sodistore"
|
||||
defaultMessage="Sodistore Max"
|
||||
/>
|
||||
</Box>
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
|
||||
{accessToSalimax && (
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/installations"
|
||||
startIcon={<BrightnessLowTwoToneIcon />}
|
||||
>
|
||||
<Box sx={{ marginTop: '3px' }}>
|
||||
<FormattedMessage id="salimax" defaultMessage="Salimax" />
|
||||
</Box>
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
|
||||
{accessToSalidomo && (
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/salidomo_installations"
|
||||
startIcon={<BrightnessLowTwoToneIcon />}
|
||||
>
|
||||
<Box sx={{ marginTop: '3px' }}>
|
||||
<FormattedMessage
|
||||
id="salidomo"
|
||||
defaultMessage="Salidomo"
|
||||
/>
|
||||
</Box>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { alpha, Box, lighten, useTheme } from '@mui/material';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride';
|
||||
import { useIntl, IntlShape } from 'react-intl';
|
||||
import Joyride, { CallBackProps, EVENTS, STATUS, Step } from 'react-joyride';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useTour } from 'src/contexts/TourContext';
|
||||
import { UserContext } from 'src/contexts/userContext';
|
||||
import { UserType } from 'src/interfaces/UserTypes';
|
||||
import {
|
||||
buildSodiohomeCustomerTourSteps, buildSodiohomePartnerTourSteps, buildSodiohomeAdminTourSteps,
|
||||
buildSalimaxCustomerTourSteps, buildSalimaxPartnerTourSteps, buildSalimaxAdminTourSteps,
|
||||
buildSodistoregridCustomerTourSteps, buildSodistoregridPartnerTourSteps, buildSodistoregridAdminTourSteps,
|
||||
buildSalidomoCustomerTourSteps, buildSalidomoPartnerTourSteps, buildSalidomoAdminTourSteps
|
||||
} from 'src/config/tourSteps';
|
||||
import { buildDynamicTourSteps } from 'src/config/tourSteps';
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
|
|
@ -22,38 +15,11 @@ interface SidebarLayoutProps {
|
|||
onSelectLanguage: (item: string) => void;
|
||||
}
|
||||
|
||||
function getTourSteps(pathname: string, userType: UserType, intl: IntlShape, isInsideInstallation: boolean): Step[] {
|
||||
const role = userType === UserType.admin ? 'admin'
|
||||
: userType === UserType.partner ? 'partner'
|
||||
: 'customer';
|
||||
|
||||
if (pathname.includes('/sodiohome_installations')) {
|
||||
if (role === 'admin') return buildSodiohomeAdminTourSteps(intl, isInsideInstallation);
|
||||
if (role === 'partner') return buildSodiohomePartnerTourSteps(intl, isInsideInstallation);
|
||||
return buildSodiohomeCustomerTourSteps(intl, isInsideInstallation);
|
||||
}
|
||||
if (pathname.includes('/salidomo_installations')) {
|
||||
if (role === 'admin') return buildSalidomoAdminTourSteps(intl, isInsideInstallation);
|
||||
if (role === 'partner') return buildSalidomoPartnerTourSteps(intl, isInsideInstallation);
|
||||
return buildSalidomoCustomerTourSteps(intl, isInsideInstallation);
|
||||
}
|
||||
if (pathname.includes('/sodistoregrid_installations')) {
|
||||
if (role === 'admin') return buildSodistoregridAdminTourSteps(intl, isInsideInstallation);
|
||||
if (role === 'partner') return buildSodistoregridPartnerTourSteps(intl, isInsideInstallation);
|
||||
return buildSodistoregridCustomerTourSteps(intl, isInsideInstallation);
|
||||
}
|
||||
// Salimax (/installations/) and Sodistore Max (/sodistore_installations/)
|
||||
if (role === 'admin') return buildSalimaxAdminTourSteps(intl, isInsideInstallation);
|
||||
if (role === 'partner') return buildSalimaxPartnerTourSteps(intl, isInsideInstallation);
|
||||
return buildSalimaxCustomerTourSteps(intl, isInsideInstallation);
|
||||
}
|
||||
|
||||
const SidebarLayout = (props: SidebarLayoutProps) => {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const { runTour, stopTour } = useTour();
|
||||
const location = useLocation();
|
||||
const { currentUser } = useContext(UserContext);
|
||||
const [tourSteps, setTourSteps] = useState<Step[]>([]);
|
||||
const [tourReady, setTourReady] = useState(false);
|
||||
|
||||
|
|
@ -64,23 +30,22 @@ const SidebarLayout = (props: SidebarLayoutProps) => {
|
|||
}
|
||||
// Delay to let child components render their tour target elements
|
||||
const timer = setTimeout(() => {
|
||||
const userType = currentUser?.userType ?? UserType.client;
|
||||
const isInsideInstallation = location.pathname.includes('/installation/');
|
||||
const steps = getTourSteps(location.pathname, userType, intl, isInsideInstallation);
|
||||
const filtered = steps.filter((step) => {
|
||||
if (typeof step.target === 'string') {
|
||||
return document.querySelector(step.target) !== null;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setTourSteps(filtered);
|
||||
const steps = buildDynamicTourSteps(intl, isInsideInstallation);
|
||||
setTourSteps(steps);
|
||||
setTourReady(true);
|
||||
}, 300);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [runTour, location.pathname, currentUser?.userType, intl]);
|
||||
}, [runTour, location.pathname, intl]);
|
||||
|
||||
const handleJoyrideCallback = (data: CallBackProps) => {
|
||||
const { status } = data;
|
||||
const { status, step, type } = data;
|
||||
if (type === EVENTS.STEP_BEFORE && step?.target) {
|
||||
const el = document.querySelector(step.target as string);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
stopTour();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue