Merge branch 'main' into sinexcel_multiinveters_configurtaion
# Conflicts: # typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx
This commit is contained in:
commit
8578b42503
|
|
@ -1471,6 +1471,91 @@ public class Controller : ControllerBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Report HTML (for PDF download) ─────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetWeeklyReportHtml))]
|
||||||
|
public async Task<ActionResult> GetWeeklyReportHtml(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 report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
|
||||||
|
var html = ReportEmailService.BuildHtmlEmail(report, lang);
|
||||||
|
return Content(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetMonthlyReportHtml))]
|
||||||
|
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, 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 report = Db.GetMonthlyReports(installationId).FirstOrDefault(r => r.Year == year && r.Month == month);
|
||||||
|
if (report == null) return BadRequest($"No monthly report found for {year}-{month:D2}.");
|
||||||
|
|
||||||
|
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
|
||||||
|
var s = ReportEmailService.GetAggregatedStrings(lang, "monthly");
|
||||||
|
var html = ReportEmailService.BuildAggregatedHtmlEmail(
|
||||||
|
report.PeriodStart, report.PeriodEnd, installation.Name,
|
||||||
|
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);
|
||||||
|
return Content(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetYearlyReportHtml))]
|
||||||
|
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, 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 report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year);
|
||||||
|
if (report == null) return BadRequest($"No yearly report found for {year}.");
|
||||||
|
|
||||||
|
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
|
||||||
|
var s = ReportEmailService.GetAggregatedStrings(lang, "yearly");
|
||||||
|
var html = ReportEmailService.BuildAggregatedHtmlEmail(
|
||||||
|
report.PeriodStart, report.PeriodEnd, installation.Name,
|
||||||
|
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);
|
||||||
|
return Content(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetDailyReportHtml))]
|
||||||
|
public ActionResult GetDailyReportHtml(Int64 installationId, String date, 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();
|
||||||
|
|
||||||
|
if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", out var parsedDate))
|
||||||
|
return BadRequest("date must be in yyyy-MM-dd format.");
|
||||||
|
|
||||||
|
var records = Db.GetDailyRecords(installationId, parsedDate, parsedDate);
|
||||||
|
if (records.Count == 0) return BadRequest($"No daily record found for {date}.");
|
||||||
|
|
||||||
|
var lang = language ?? user.Language ?? "en";
|
||||||
|
var html = ReportEmailService.BuildDailyHtmlEmail(records[0], installation.Name, lang);
|
||||||
|
return Content(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet(nameof(GetWeeklyReportSummaries))]
|
[HttpGet(nameof(GetWeeklyReportSummaries))]
|
||||||
public async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
|
public async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
|
||||||
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
|
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ public class Installation : TreeNode
|
||||||
public int BatteryClusterNumber { get; set; } = 0;
|
public int BatteryClusterNumber { get; set; } = 0;
|
||||||
public int BatteryNumber { get; set; } = 0;
|
public int BatteryNumber { get; set; } = 0;
|
||||||
public string BatterySerialNumbers { get; set; } = "";
|
public string BatterySerialNumbers { get; set; } = "";
|
||||||
|
public string PvStringsPerInverter { get; set; } = "";
|
||||||
|
public string InstallationModel { get; set; } = "";
|
||||||
|
public string ExternalEms { get; set; } = "No";
|
||||||
|
|
||||||
[Ignore]
|
[Ignore]
|
||||||
public String OrderNumbers { get; set; }
|
public String OrderNumbers { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,11 @@ public static partial class Db
|
||||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
fileConnection.Execute("UPDATE Installation SET PvStringsPerInverter = '' WHERE PvStringsPerInverter IS NULL");
|
||||||
|
|
||||||
return fileConnection;
|
return fileConnection;
|
||||||
//return CopyDbToMemory(fileConnection);
|
//return CopyDbToMemory(fileConnection);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
|
|
@ -1,8 +1,12 @@
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
Alert,
|
Alert,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -16,10 +20,11 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { Close as CloseIcon } from '@mui/icons-material';
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import React, { useContext, useState, useEffect, useRef } from 'react';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import React, { useContext, useState, useEffect } from 'react';
|
||||||
import { I_S3Credentials } from '../../../interfaces/S3Types';
|
import { I_S3Credentials } from '../../../interfaces/S3Types';
|
||||||
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
import { I_Installation } from '../../../interfaces/InstallationTypes';
|
||||||
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
|
||||||
|
|
@ -28,6 +33,16 @@ import routes from '../../../Resources/routes.json';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import axiosConfig from '../../../Resources/axiosConfig';
|
import axiosConfig from '../../../Resources/axiosConfig';
|
||||||
|
import {
|
||||||
|
INSTALLATION_PRESETS,
|
||||||
|
PresetConfig,
|
||||||
|
BatterySnTree,
|
||||||
|
parseBatterySnTree,
|
||||||
|
buildEmptyTree,
|
||||||
|
remapTree,
|
||||||
|
computeFlatValues,
|
||||||
|
wouldLoseData,
|
||||||
|
} from './installationSetupUtils';
|
||||||
|
|
||||||
interface InformationSodistorehomeProps {
|
interface InformationSodistorehomeProps {
|
||||||
values: I_Installation;
|
values: I_Installation;
|
||||||
|
|
@ -43,35 +58,205 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
const { currentUser } = context;
|
const { currentUser } = context;
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
const [formValues, setFormValues] = useState(props.values);
|
const [formValues, setFormValues] = useState(props.values);
|
||||||
const requiredFields = ['name', 'region', 'location', 'country'];
|
const requiredFields = ['name', 'region', 'location', 'country'];
|
||||||
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
|
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [pendingPreset, setPendingPreset] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [batteryNumber, setBatteryNumber] = useState(0);
|
|
||||||
const [batterySerialNumbers, setBatterySerialNumbers] = useState<string[]>(
|
// Parse inverter/datalogger serial numbers from various legacy formats:
|
||||||
[]
|
// Slash-separated: "SN001/SN002"
|
||||||
);
|
// Labeled comma: "Inverter 1: SN001, Inverter 2: SN002"
|
||||||
|
// Labeled comma: "Datalogger 1: SN001, Datalogger 2: SN002"
|
||||||
|
// Plain string: "SN001"
|
||||||
|
const parseSerialNumbers = (value: string | undefined): string[] => {
|
||||||
|
if (!value || value.trim() === '') return [];
|
||||||
|
|
||||||
|
// Check for labeled comma format: "Inverter 1: SN001, Inverter 2: SN002"
|
||||||
|
if (/(?:Inverter|Datalogger)\s*\d+\s*:/i.test(value)) {
|
||||||
|
const matches = value.match(/(?:Inverter|Datalogger)\s*\d+\s*:\s*([^,]+)/gi);
|
||||||
|
if (matches) {
|
||||||
|
return matches
|
||||||
|
.map((m) => m.replace(/^(?:Inverter|Datalogger)\s*\d+\s*:\s*/i, '').trim())
|
||||||
|
.filter((s) => s !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash-separated format: "SN001/SN002" — preserve empty positions
|
||||||
|
if (value.includes('/')) {
|
||||||
|
return value.split('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single value
|
||||||
|
return [value.trim()];
|
||||||
|
};
|
||||||
|
|
||||||
const DeviceTypes = [
|
const DeviceTypes = [
|
||||||
{ id: 3, name: 'Growatt' },
|
{ id: 3, name: 'Growatt' },
|
||||||
{ id: 4, name: 'Sinexcel' }
|
{ id: 4, name: 'Sinexcel' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const batterySnRefs = useRef<(HTMLInputElement | null)[]>([]);
|
// 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;
|
||||||
|
|
||||||
// Initialize battery data from props
|
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
|
||||||
useEffect(() => {
|
if (presetConfig) {
|
||||||
if (props.values.batteryNumber) {
|
return parseBatterySnTree(props.values.batterySerialNumbers || '', presetConfig);
|
||||||
setBatteryNumber(props.values.batteryNumber);
|
|
||||||
}
|
}
|
||||||
if (props.values.batterySerialNumbers) {
|
return [];
|
||||||
const serialNumbers = props.values.batterySerialNumbers
|
});
|
||||||
.split(',')
|
|
||||||
.filter((sn) => sn.trim() !== '');
|
const [inverterSerialNumbers, setInverterSerialNumbers] = useState<string[]>(() =>
|
||||||
setBatterySerialNumbers(serialNumbers);
|
parseSerialNumbers(props.values.inverterSN)
|
||||||
|
);
|
||||||
|
const [dataloggerSerialNumbers, setDataloggerSerialNumbers] = useState<string[]>(() =>
|
||||||
|
parseSerialNumbers(props.values.dataloggerSN)
|
||||||
|
);
|
||||||
|
|
||||||
|
// PV strings per inverter — persisted as comma-separated, default "1" per inverter
|
||||||
|
const [pvStringsPerInverter, setPvStringsPerInverter] = useState<string[]>(() => {
|
||||||
|
const stored = props.values.pvStringsPerInverter;
|
||||||
|
if (stored && stored.trim() !== '') {
|
||||||
|
return stored.split(',').map((s) => s.trim());
|
||||||
}
|
}
|
||||||
}, []);
|
const invCount = presetConfig?.length
|
||||||
|
|| parseSerialNumbers(props.values.inverterSN).length
|
||||||
|
|| 1;
|
||||||
|
return Array.from({ length: invCount }, () => '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePresetChange = (newPreset: string) => {
|
||||||
|
const newConfig = INSTALLATION_PRESETS[newPreset];
|
||||||
|
if (!newConfig) return;
|
||||||
|
|
||||||
|
// Check for data loss — either from existing tree or legacy flat data
|
||||||
|
const treeToCheck = batterySnTree.length > 0
|
||||||
|
? batterySnTree
|
||||||
|
: props.values.batterySerialNumbers
|
||||||
|
? parseBatterySnTree(props.values.batterySerialNumbers, newConfig)
|
||||||
|
: [];
|
||||||
|
if (treeToCheck.length > 0 && wouldLoseData(treeToCheck, newConfig)) {
|
||||||
|
setPendingPreset(newPreset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(newPreset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetChangeConfirm = () => {
|
||||||
|
if (pendingPreset) {
|
||||||
|
applyPreset(pendingPreset);
|
||||||
|
setPendingPreset(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetChangeCancel = () => {
|
||||||
|
setPendingPreset(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (newPreset: string) => {
|
||||||
|
const newConfig = INSTALLATION_PRESETS[newPreset];
|
||||||
|
if (!newConfig) return;
|
||||||
|
|
||||||
|
setSelectedPreset(newPreset);
|
||||||
|
|
||||||
|
let newTree: BatterySnTree;
|
||||||
|
if (presetConfig && batterySnTree.length > 0) {
|
||||||
|
// Switching between presets — remap existing tree
|
||||||
|
newTree = remapTree(batterySnTree, newConfig);
|
||||||
|
} else if (props.values.batterySerialNumbers) {
|
||||||
|
// First preset selection on legacy installation — parse existing flat data
|
||||||
|
newTree = parseBatterySnTree(props.values.batterySerialNumbers, newConfig);
|
||||||
|
} else {
|
||||||
|
newTree = buildEmptyTree(newConfig);
|
||||||
|
}
|
||||||
|
setBatterySnTree(newTree);
|
||||||
|
|
||||||
|
const newInvCount = newConfig.length;
|
||||||
|
const newInvSNs = Array.from({ length: newInvCount }, (_, i) =>
|
||||||
|
inverterSerialNumbers[i] || ''
|
||||||
|
);
|
||||||
|
const newDlSNs = Array.from({ length: newInvCount }, (_, i) =>
|
||||||
|
dataloggerSerialNumbers[i] || ''
|
||||||
|
);
|
||||||
|
const newPvStrings = Array.from({ length: newInvCount }, (_, i) =>
|
||||||
|
pvStringsPerInverter[i] || '1'
|
||||||
|
);
|
||||||
|
setInverterSerialNumbers(newInvSNs);
|
||||||
|
setDataloggerSerialNumbers(newDlSNs);
|
||||||
|
setPvStringsPerInverter(newPvStrings);
|
||||||
|
|
||||||
|
const flat = computeFlatValues(newConfig, newTree);
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
...flat,
|
||||||
|
installationModel: newPreset,
|
||||||
|
inverterSN: newInvSNs.join('/'),
|
||||||
|
dataloggerSN: newDlSNs.join('/'),
|
||||||
|
pvStringsPerInverter: newPvStrings.join(','),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInverterSnChange = (invIdx: number, value: string) => {
|
||||||
|
const updated = [...inverterSerialNumbers];
|
||||||
|
updated[invIdx] = value;
|
||||||
|
setInverterSerialNumbers(updated);
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
inverterSN: updated.join('/'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDataloggerSnChange = (invIdx: number, value: string) => {
|
||||||
|
const updated = [...dataloggerSerialNumbers];
|
||||||
|
updated[invIdx] = value;
|
||||||
|
setDataloggerSerialNumbers(updated);
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
dataloggerSN: updated.join('/'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePvStringsChange = (invIdx: number, value: string) => {
|
||||||
|
if (value !== '' && !/^\d+$/.test(value)) return;
|
||||||
|
const updated = [...pvStringsPerInverter];
|
||||||
|
updated[invIdx] = value;
|
||||||
|
setPvStringsPerInverter(updated);
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
pvStringsPerInverter: updated.join(','),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatterySnTreeChange = (
|
||||||
|
invIdx: number,
|
||||||
|
clIdx: number,
|
||||||
|
batIdx: number,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const newTree = batterySnTree.map((inv, i) =>
|
||||||
|
i === invIdx
|
||||||
|
? inv.map((cl, j) =>
|
||||||
|
j === clIdx
|
||||||
|
? cl.map((bat, k) => (k === batIdx ? value : bat))
|
||||||
|
: cl
|
||||||
|
)
|
||||||
|
: inv
|
||||||
|
);
|
||||||
|
setBatterySnTree(newTree);
|
||||||
|
if (presetConfig) {
|
||||||
|
const flat = computeFlatValues(presetConfig, newTree);
|
||||||
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
|
...flat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const installationContext = useContext(InstallationsContext);
|
const installationContext = useContext(InstallationsContext);
|
||||||
const {
|
const {
|
||||||
|
|
@ -93,54 +278,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatteryNumberChange = (e) => {
|
|
||||||
const inputValue = e.target.value;
|
|
||||||
// Only allow numeric input
|
|
||||||
if (inputValue === '' || /^\d+$/.test(inputValue)) {
|
|
||||||
const value = inputValue === '' ? 0 : parseInt(inputValue);
|
|
||||||
setBatteryNumber(value);
|
|
||||||
|
|
||||||
if (value > 0) {
|
|
||||||
// Resize array: preserve existing serial numbers, add empty for new slots
|
|
||||||
const newSerialNumbers = Array.from({ length: value }, (_, index) => {
|
|
||||||
return batterySerialNumbers[index] || '';
|
|
||||||
});
|
|
||||||
setBatterySerialNumbers(newSerialNumbers);
|
|
||||||
|
|
||||||
setFormValues({
|
|
||||||
...formValues,
|
|
||||||
batteryNumber: value,
|
|
||||||
batterySerialNumbers: newSerialNumbers.filter((sn) => sn !== '').join(',')
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Field is empty (user is mid-edit) — don't clear serial numbers
|
|
||||||
setFormValues({
|
|
||||||
...formValues,
|
|
||||||
batteryNumber: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatterySerialNumberChange = (index: number, value: string) => {
|
|
||||||
const updatedSerialNumbers = [...batterySerialNumbers];
|
|
||||||
updatedSerialNumbers[index] = value;
|
|
||||||
setBatterySerialNumbers(updatedSerialNumbers);
|
|
||||||
setFormValues({
|
|
||||||
...formValues,
|
|
||||||
batterySerialNumbers: updatedSerialNumbers.filter((sn) => sn !== '').join(',')
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatterySnKeyDown = (e: React.KeyboardEvent, index: number) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const nextIndex = index + 1;
|
|
||||||
if (nextIndex < batteryNumber && batterySnRefs.current[nextIndex]) {
|
|
||||||
batterySnRefs.current[nextIndex].focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|
@ -292,6 +429,75 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreset !== null && (
|
||||||
|
<Modal
|
||||||
|
open={pendingPreset !== null}
|
||||||
|
onClose={handlePresetChangeCancel}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 450,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 'bold', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="confirmPresetSwitch"
|
||||||
|
defaultMessage="Switching to a smaller configuration will remove some battery serial number entries. Continue?"
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sx={{
|
||||||
|
marginTop: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
bgcolor: '#ffc04d',
|
||||||
|
color: '#111111',
|
||||||
|
'&:hover': { bgcolor: '#f7b34d' }
|
||||||
|
}}
|
||||||
|
onClick={handlePresetChangeConfirm}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="continue" defaultMessage="Continue" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
sx={{
|
||||||
|
marginTop: 2,
|
||||||
|
marginLeft: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
bgcolor: '#ffc04d',
|
||||||
|
color: '#111111',
|
||||||
|
'&:hover': { bgcolor: '#f7b34d' }
|
||||||
|
}}
|
||||||
|
onClick={handlePresetChangeCancel}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
|
|
@ -310,14 +516,13 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
noValidate
|
noValidate
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="generalInfo" defaultMessage="General Info" />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={<FormattedMessage id="installation_name" defaultMessage="Installation Name" />}
|
||||||
<FormattedMessage
|
|
||||||
id="installation_name"
|
|
||||||
defaultMessage="Installation Name"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="name"
|
name="name"
|
||||||
value={formValues.name}
|
value={formValues.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -328,9 +533,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={<FormattedMessage id="region" defaultMessage="Region" />}
|
||||||
<FormattedMessage id="region" defaultMessage="Region" />
|
|
||||||
}
|
|
||||||
name="region"
|
name="region"
|
||||||
value={formValues.region}
|
value={formValues.region}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -343,12 +546,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={<FormattedMessage id="location" defaultMessage="Location" />}
|
||||||
<FormattedMessage
|
|
||||||
id="location"
|
|
||||||
defaultMessage="Location"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="location"
|
name="location"
|
||||||
value={formValues.location}
|
value={formValues.location}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -361,9 +559,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={<FormattedMessage id="country" defaultMessage="Country" />}
|
||||||
<FormattedMessage id="country" defaultMessage="Country" />
|
|
||||||
}
|
|
||||||
name="country"
|
name="country"
|
||||||
value={formValues.country}
|
value={formValues.country}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -375,87 +571,22 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{formValues.installationModel && (
|
||||||
<Autocomplete
|
|
||||||
freeSolo
|
|
||||||
options={networkProviders}
|
|
||||||
value={formValues.networkProvider || ''}
|
|
||||||
onChange={(_e, val) =>
|
|
||||||
setFormValues({
|
|
||||||
...formValues,
|
|
||||||
networkProvider: (val as string) || ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onInputChange={(_e, val) =>
|
|
||||||
setFormValues({
|
|
||||||
...formValues,
|
|
||||||
networkProvider: val || ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canEdit && !isPartner}
|
|
||||||
loading={loadingProviders}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id="networkProvider"
|
|
||||||
defaultMessage="Network Provider"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
InputProps={{
|
|
||||||
...params.InputProps,
|
|
||||||
endAdornment: (
|
|
||||||
<>
|
|
||||||
{loadingProviders ? (
|
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : null}
|
|
||||||
{params.InputProps.endAdornment}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={<FormattedMessage id="installationModel" defaultMessage="Installation Model" />}
|
||||||
<FormattedMessage id="vpnip" defaultMessage="VPN IP" />
|
value={formValues.installationModel}
|
||||||
}
|
|
||||||
name="vpnIp"
|
|
||||||
value={formValues.vpnIp}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
inputProps={{ readOnly: true }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||||
fullWidth
|
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||||
sx={{
|
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
|
||||||
marginLeft: 1,
|
|
||||||
marginTop: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
width: 440
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InputLabel
|
|
||||||
sx={{
|
|
||||||
fontSize: 14,
|
|
||||||
backgroundColor: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormattedMessage
|
|
||||||
id="DeviceType"
|
|
||||||
defaultMessage="Device Type"
|
|
||||||
/>
|
|
||||||
</InputLabel>
|
</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
name="device"
|
name="device"
|
||||||
|
|
@ -472,125 +603,84 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(canEdit || isPartner) && (
|
<div>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={networkProviders}
|
||||||
|
value={formValues.networkProvider || ''}
|
||||||
|
onChange={(_e, val) =>
|
||||||
|
setFormValues({ ...formValues, networkProvider: (val as string) || '' })
|
||||||
|
}
|
||||||
|
onInputChange={(_e, val) =>
|
||||||
|
setFormValues({ ...formValues, networkProvider: val || '' })
|
||||||
|
}
|
||||||
|
disabled={!canEdit && !isPartner}
|
||||||
|
loading={loadingProviders}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={<FormattedMessage id="networkProvider" defaultMessage="Network Provider" />}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
<>
|
<>
|
||||||
<div>
|
{loadingProviders ? <CircularProgress size={20} /> : null}
|
||||||
<TextField
|
{params.InputProps.endAdornment}
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id="installationSerialNumber"
|
|
||||||
defaultMessage="Installation Serial Number"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="serialNumber"
|
|
||||||
value={formValues.serialNumber}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ readOnly: !canEdit }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<TextField
|
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id="inverterSN"
|
|
||||||
defaultMessage="Inverter Serial Number"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="inverterSN"
|
|
||||||
value={formValues.inverterSN}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ readOnly: !canEdit }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<TextField
|
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id="dataloggerSN"
|
|
||||||
defaultMessage="Datalogger Serial Number"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="dataloggerSN"
|
|
||||||
value={formValues.dataloggerSN}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ readOnly: !canEdit }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<TextField
|
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id="batteryClusterNumber"
|
|
||||||
defaultMessage="Battery Cluster Number"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="batteryClusterNumber"
|
|
||||||
value={formValues.batteryClusterNumber}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ readOnly: !canEdit }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<TextField
|
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id="batteryNumber"
|
|
||||||
defaultMessage="Battery Number"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="batteryNumber"
|
|
||||||
type="text"
|
|
||||||
value={batteryNumber === 0 ? '' : batteryNumber}
|
|
||||||
onChange={handleBatteryNumberChange}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
placeholder={canEdit ? 'Enter number of batteries' : ''}
|
|
||||||
inputProps={{ readOnly: !canEdit }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{batteryNumber > 0 &&
|
|
||||||
batterySerialNumbers.map((serialNumber, index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<TextField
|
|
||||||
label={`Battery Pack ${index + 1}`}
|
|
||||||
name={`batterySN${index + 1}`}
|
|
||||||
value={serialNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleBatterySerialNumberChange(index, e.target.value)
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => handleBatterySnKeyDown(e, index)}
|
|
||||||
inputRef={(el) => (batterySnRefs.current[index] = el)}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
placeholder={canEdit ? 'Scan or enter serial number' : ''}
|
|
||||||
inputProps={{ readOnly: !canEdit }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||||
|
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||||
|
<FormattedMessage id="externalEms" defaultMessage="External EMS" />
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="externalEms"
|
||||||
|
value={
|
||||||
|
['No', 'Solar Manager', 'Smart Fox', 'Loxone'].includes(formValues.externalEms || 'No')
|
||||||
|
? (formValues.externalEms || 'No')
|
||||||
|
: 'Other'
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value as string;
|
||||||
|
setFormValues({ ...formValues, externalEms: val });
|
||||||
|
}}
|
||||||
|
inputProps={{ readOnly: !canEdit && !isPartner }}
|
||||||
|
>
|
||||||
|
<MenuItem value="No"><FormattedMessage id="emsNo" defaultMessage="No" /></MenuItem>
|
||||||
|
<MenuItem value="Solar Manager">Solar Manager</MenuItem>
|
||||||
|
<MenuItem value="Smart Fox">Smart Fox</MenuItem>
|
||||||
|
<MenuItem value="Loxone">Loxone</MenuItem>
|
||||||
|
<MenuItem value="Other"><FormattedMessage id="emsOther" defaultMessage="Other" /></MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formValues.externalEms &&
|
||||||
|
!['No', 'Solar Manager', 'Smart Fox', 'Loxone'].includes(formValues.externalEms) && (
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
label={<FormattedMessage id="externalEmsOther" defaultMessage="External EMS (specify)" />}
|
||||||
|
name="externalEms"
|
||||||
|
value={formValues.externalEms === 'Other' ? '' : formValues.externalEms}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ readOnly: !canEdit && !isPartner }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={<FormattedMessage id="information" defaultMessage="Information" />}
|
||||||
<FormattedMessage
|
|
||||||
id="information"
|
|
||||||
defaultMessage="Information"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name="information"
|
name="information"
|
||||||
value={formValues.information}
|
value={formValues.information}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -600,35 +690,202 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEdit && (
|
{(canEdit || isPartner) && (
|
||||||
<>
|
<>
|
||||||
|
<Typography variant="h6" sx={{ mt: 4, mb: 2, fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="installationSetup" defaultMessage="Installation Setup" />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<FormControl sx={{ m: 1, width: '50ch' }}>
|
||||||
label="S3 Bucket Name"
|
<InputLabel
|
||||||
name="s3writesecretkey"
|
shrink
|
||||||
value={
|
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
|
||||||
formValues.s3BucketId +
|
>
|
||||||
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
|
Installation Model
|
||||||
}
|
</InputLabel>
|
||||||
variant="outlined"
|
<Select
|
||||||
fullWidth
|
value={selectedPreset}
|
||||||
/>
|
onChange={(e) => handlePresetChange(e.target.value as string)}
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
displayEmpty
|
||||||
|
notched
|
||||||
|
>
|
||||||
|
<MenuItem value="" disabled>
|
||||||
|
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
|
||||||
|
</MenuItem>
|
||||||
|
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||||
|
<MenuItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
label={<FormattedMessage id="installationSerialNumber" defaultMessage="Installation Serial Number" />}
|
||||||
|
name="serialNumber"
|
||||||
|
value={formValues.serialNumber}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presetConfig && presetConfig.map((clusters, invIdx) => {
|
||||||
|
const invSn = inverterSerialNumbers[invIdx] || '';
|
||||||
|
const totalBat = clusters.reduce((a, b) => a + b, 0);
|
||||||
|
const filledBat = (batterySnTree[invIdx] || []).flat().filter((s) => s !== '').length;
|
||||||
|
const filledClusters = (batterySnTree[invIdx] || []).filter(
|
||||||
|
(cl) => cl.some((s) => s !== '')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
key={`inv-${invIdx}`}
|
||||||
|
defaultExpanded={!invSn}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="inverterN" defaultMessage="Inverter {n}" values={{ n: invIdx + 1 }} />
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={intl.formatMessage(
|
||||||
|
{ id: 'clustersBatteriesSummary', defaultMessage: '{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries' },
|
||||||
|
{ filledClusters, totalClusters: clusters.length, filledBat, totalBat }
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<TextField
|
||||||
|
label={intl.formatMessage({ id: 'inverterNSerialNumber', defaultMessage: 'Inverter {n} Serial Number' }, { n: invIdx + 1 })}
|
||||||
|
value={invSn}
|
||||||
|
onChange={(e) => handleInverterSnChange(invIdx, e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={intl.formatMessage({ id: 'dataloggerNSerialNumber', defaultMessage: 'Datalogger {n} Serial Number' }, { n: invIdx + 1 })}
|
||||||
|
value={dataloggerSerialNumbers[invIdx] || ''}
|
||||||
|
onChange={(e) => handleDataloggerSnChange(invIdx, e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={intl.formatMessage({ id: 'pvStringsOnInverterN', defaultMessage: 'Number of PV Strings on Inverter {n}' }, { n: invIdx + 1 })}
|
||||||
|
type="text"
|
||||||
|
value={pvStringsPerInverter[invIdx] || ''}
|
||||||
|
onChange={(e) => handlePvStringsChange(invIdx, e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{clusters.map((batteryCount, clIdx) => {
|
||||||
|
const filledInCluster = (batterySnTree[invIdx]?.[clIdx] || []).filter((s) => s !== '').length;
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
key={`cl-${invIdx}-${clIdx}`}
|
||||||
|
defaultExpanded={true}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="clusterN" defaultMessage="Cluster {n}" values={{ n: clIdx + 1 }} />
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={intl.formatMessage(
|
||||||
|
{ id: 'batteriesSummary', defaultMessage: '{filled}/{total} batteries' },
|
||||||
|
{ filled: filledInCluster, total: batteryCount }
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
{Array.from({ length: batteryCount }, (_, batIdx) => (
|
||||||
|
<TextField
|
||||||
|
key={`bat-${invIdx}-${clIdx}-${batIdx}`}
|
||||||
|
label={intl.formatMessage({ id: 'batteryNSerialNumber', defaultMessage: 'Battery {n} Serial Number' }, { n: batIdx + 1 })}
|
||||||
|
value={batterySnTree[invIdx]?.[clIdx]?.[batIdx] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleBatterySnTreeChange(invIdx, clIdx, batIdx, e.target.value)
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
placeholder={canEdit ? 'Scan or enter serial number' : ''}
|
||||||
|
inputProps={{ readOnly: !canEdit }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
); })}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" sx={{ mt: 4, mb: 2, fontWeight: 'bold' }}>
|
||||||
|
<FormattedMessage id="adminSection" defaultMessage="Admin" />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
label={<FormattedMessage id="vpnip" defaultMessage="VPN IP" />}
|
||||||
|
name="vpnIp"
|
||||||
|
value={formValues.vpnIp}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
label="S3 Bucket Name"
|
||||||
|
value={formValues.s3BucketId + '-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="S3 Write Key"
|
label="S3 Write Key"
|
||||||
name="s3writesecretkey"
|
|
||||||
value={formValues.s3WriteKey}
|
value={formValues.s3WriteKey}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="S3 Write Secret Key"
|
label="S3 Write Secret Key"
|
||||||
name="s3writesecretkey"
|
|
||||||
value={formValues.s3WriteSecret}
|
value={formValues.s3WriteSecret}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
// [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]],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildEmptyTree = (preset: PresetConfig): BatterySnTree => {
|
||||||
|
return preset.map((inv) =>
|
||||||
|
inv.map((batteryCount) => Array.from({ length: batteryCount }, () => ''))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseBatterySnTree = (
|
||||||
|
raw: string,
|
||||||
|
preset: PresetConfig
|
||||||
|
): BatterySnTree => {
|
||||||
|
if (!raw || raw.trim() === '') {
|
||||||
|
return buildEmptyTree(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStructured = raw.includes('/') || raw.includes('|');
|
||||||
|
|
||||||
|
if (isStructured) {
|
||||||
|
const inverters = raw.split('/');
|
||||||
|
return preset.map((invPreset, invIdx) => {
|
||||||
|
const clusterStr = inverters[invIdx] || '';
|
||||||
|
const clusters = clusterStr ? clusterStr.split('|') : [];
|
||||||
|
return invPreset.map((batteryCount, clIdx) => {
|
||||||
|
const batteries = clusters[clIdx]
|
||||||
|
? clusters[clIdx].split(',').map((s) => s.trim())
|
||||||
|
: [];
|
||||||
|
return Array.from({ length: batteryCount }, (_, i) => batteries[i] || '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy flat format: distribute by preset layout
|
||||||
|
const allSns = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s !== '');
|
||||||
|
let idx = 0;
|
||||||
|
return preset.map((inv) =>
|
||||||
|
inv.map((batteryCount) =>
|
||||||
|
Array.from({ length: batteryCount }, () => allSns[idx++] || '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeBatterySnTree = (tree: BatterySnTree): string => {
|
||||||
|
return tree
|
||||||
|
.map((inv) => inv.map((cluster) => cluster.join(',')).join('|'))
|
||||||
|
.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remapTree = (
|
||||||
|
oldTree: BatterySnTree,
|
||||||
|
newPreset: PresetConfig
|
||||||
|
): BatterySnTree => {
|
||||||
|
return newPreset.map((inv, invIdx) =>
|
||||||
|
inv.map((batteryCount, clIdx) =>
|
||||||
|
Array.from(
|
||||||
|
{ length: batteryCount },
|
||||||
|
(_, batIdx) => oldTree[invIdx]?.[clIdx]?.[batIdx] || ''
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeFlatValues = (
|
||||||
|
preset: PresetConfig,
|
||||||
|
tree: BatterySnTree
|
||||||
|
) => {
|
||||||
|
const totalBatteries = preset.flat().reduce((a, b) => a + b, 0);
|
||||||
|
const totalClusters = preset.reduce((sum, inv) => sum + inv.length, 0);
|
||||||
|
return {
|
||||||
|
batteryNumber: totalBatteries,
|
||||||
|
batteryClusterNumber: totalClusters,
|
||||||
|
batterySerialNumbers: serializeBatterySnTree(tree),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const wouldLoseData = (
|
||||||
|
oldTree: BatterySnTree,
|
||||||
|
newPreset: PresetConfig
|
||||||
|
): boolean => {
|
||||||
|
for (let invIdx = 0; invIdx < oldTree.length; invIdx++) {
|
||||||
|
for (let clIdx = 0; clIdx < (oldTree[invIdx] || []).length; clIdx++) {
|
||||||
|
for (
|
||||||
|
let batIdx = 0;
|
||||||
|
batIdx < (oldTree[invIdx][clIdx] || []).length;
|
||||||
|
batIdx++
|
||||||
|
) {
|
||||||
|
const sn = oldTree[invIdx][clIdx][batIdx];
|
||||||
|
if (sn && sn.trim() !== '') {
|
||||||
|
if (invIdx >= newPreset.length) return true;
|
||||||
|
if (clIdx >= (newPreset[invIdx] || []).length) return true;
|
||||||
|
const newBatCount = newPreset[invIdx]?.[clIdx] ?? 0;
|
||||||
|
if (batIdx >= newBatCount) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
@ -97,10 +97,12 @@ function getCurrentWeekDays(currentMonday: Date): Date[] {
|
||||||
|
|
||||||
export default function DailySection({
|
export default function DailySection({
|
||||||
installationId,
|
installationId,
|
||||||
onHasData
|
onHasData,
|
||||||
|
onPeriodChange
|
||||||
}: {
|
}: {
|
||||||
installationId: number;
|
installationId: number;
|
||||||
onHasData?: (hasData: boolean) => void;
|
onHasData?: (hasData: boolean) => void;
|
||||||
|
onPeriodChange?: (date: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
||||||
|
|
@ -113,7 +115,11 @@ export default function DailySection({
|
||||||
|
|
||||||
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
|
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
|
||||||
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday));
|
const [selectedDate, setSelectedDate] = useState(() => {
|
||||||
|
const date = formatDateISO(yesterday);
|
||||||
|
onPeriodChange?.(date);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
|
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
|
||||||
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
const [loadingWeek, setLoadingWeek] = useState(false);
|
const [loadingWeek, setLoadingWeek] = useState(false);
|
||||||
|
|
@ -174,6 +180,7 @@ export default function DailySection({
|
||||||
const handleStripSelect = (date: string) => {
|
const handleStripSelect = (date: string) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setNoData(false);
|
setNoData(false);
|
||||||
|
onPeriodChange?.(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dt = new Date(selectedDate + 'T00:00:00');
|
const dt = new Date(selectedDate + 'T00:00:00');
|
||||||
|
|
|
||||||
|
|
@ -104,17 +104,19 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
inverterNumber: inverterNum,
|
inverterNumber: inverterNum,
|
||||||
batteriesCountPerInverter: batteriesPerInverter,
|
batteriesCountPerInverter: batteriesPerInverter,
|
||||||
batteriesCount: props.values.Config.BatteriesCount,
|
batteriesCount: props.values.Config.BatteriesCount,
|
||||||
clusterNumber: props.values.Config.ClusterNumber || 1,
|
clusterNumber: props.values.Config.ClusterNumber ?? 1,
|
||||||
PvNumber: props.values.Config.PvNumber ?? 0,
|
PvNumber: props.values.Config.PvNumber ?? 0,
|
||||||
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
|
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
|
||||||
startTimeChargeandDischargeDayandTime:
|
startTimeChargeandDischargeDayandTime: (() => {
|
||||||
props.values.Config?.StartTimeChargeandDischargeDayandTime
|
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
|
||||||
? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate()
|
const parsed = raw ? dayjs(raw) : null;
|
||||||
: null,
|
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||||
stopTimeChargeandDischargeDayandTime:
|
})(),
|
||||||
props.values.Config?.StopTimeChargeandDischargeDayandTime
|
stopTimeChargeandDischargeDayandTime: (() => {
|
||||||
? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate()
|
const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime;
|
||||||
: null,
|
const parsed = raw ? dayjs(raw) : null;
|
||||||
|
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||||
|
})(),
|
||||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -212,9 +214,11 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add time validation function
|
// Add time validation function — only relevant for Sinexcel BatteryPriority
|
||||||
const validateTimeOnly = () => {
|
const validateTimeOnly = () => {
|
||||||
if (formValues.startTimeChargeandDischargeDayandTime &&
|
if (device === 4 &&
|
||||||
|
OperatingPriorityOptions[formValues.operatingPriority] === 'BatteryPriority' &&
|
||||||
|
formValues.startTimeChargeandDischargeDayandTime &&
|
||||||
formValues.stopTimeChargeandDischargeDayandTime) {
|
formValues.stopTimeChargeandDischargeDayandTime) {
|
||||||
const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours();
|
const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours();
|
||||||
const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes();
|
const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils';
|
||||||
|
|
||||||
interface SodistorehomeInstallationFormPros {
|
interface SodistorehomeInstallationFormPros {
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
|
|
@ -33,8 +34,10 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
location: '',
|
location: '',
|
||||||
country: '',
|
country: '',
|
||||||
vpnIp: '',
|
vpnIp: '',
|
||||||
|
installationModel: '',
|
||||||
|
externalEms: 'No',
|
||||||
});
|
});
|
||||||
const requiredFields = ['name', 'location', 'country', 'vpnIp'];
|
const requiredFields = ['name', 'location', 'country', 'vpnIp', 'installationModel'];
|
||||||
|
|
||||||
const DeviceTypes = [
|
const DeviceTypes = [
|
||||||
{ id: 3, name: 'Growatt' },
|
{ id: 3, name: 'Growatt' },
|
||||||
|
|
@ -171,6 +174,42 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
error={formValues.installationModel === ''}
|
||||||
|
sx={{
|
||||||
|
marginTop: 1,
|
||||||
|
marginBottom: 1,
|
||||||
|
width: 390
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputLabel
|
||||||
|
sx={{
|
||||||
|
fontSize: 14,
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="installationModel"
|
||||||
|
defaultMessage="Installation Model"
|
||||||
|
/>
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="installationModel"
|
||||||
|
value={formValues.installationModel || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||||
|
<MenuItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,8 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
const [dailyHasData, setDailyHasData] = useState(false);
|
const [dailyHasData, setDailyHasData] = useState(false);
|
||||||
const [weeklyHasData, setWeeklyHasData] = useState(false);
|
const [weeklyHasData, setWeeklyHasData] = useState(false);
|
||||||
|
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||||
|
const [reportPeriod, setReportPeriod] = useState<{ start: string; end: string; year?: number; month?: number } | null>(null);
|
||||||
const weeklyRef = useRef<WeeklySectionHandle>(null);
|
const weeklyRef = useRef<WeeklySectionHandle>(null);
|
||||||
|
|
||||||
const fetchReportData = () => {
|
const fetchReportData = () => {
|
||||||
|
|
@ -302,16 +304,56 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
return false;
|
return false;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const handleDownloadPdf = async () => {
|
||||||
|
const reportType = tabs[safeTab]?.key ?? 'report';
|
||||||
|
let endpoint = '';
|
||||||
|
const params: Record<string, any> = { installationId, language: intl.locale };
|
||||||
|
|
||||||
|
switch (reportType) {
|
||||||
|
case 'daily':
|
||||||
|
endpoint = '/GetDailyReportHtml';
|
||||||
|
if (reportPeriod?.start) params.date = reportPeriod.start;
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
endpoint = '/GetWeeklyReportHtml';
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
endpoint = '/GetMonthlyReportHtml';
|
||||||
|
if (reportPeriod?.year) params.year = reportPeriod.year;
|
||||||
|
if (reportPeriod?.month) params.month = reportPeriod.month;
|
||||||
|
break;
|
||||||
|
case 'yearly':
|
||||||
|
endpoint = '/GetYearlyReportHtml';
|
||||||
|
if (reportPeriod?.year) params.year = reportPeriod.year;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint) return;
|
||||||
|
|
||||||
|
setDownloadingPdf(true);
|
||||||
|
try {
|
||||||
|
const res = await axiosConfig.get(endpoint, { params, responseType: 'text' });
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
const dateRange = reportPeriod
|
||||||
|
? `${reportPeriod.start.replace(/-/g, '')}-${reportPeriod.end.replace(/-/g, '')}`
|
||||||
|
: new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||||
|
|
||||||
|
printWindow.document.write(res.data);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.document.title = `inesco-energy-${installationId}-${reportType}-${dateRange}`;
|
||||||
|
printWindow.onafterprint = () => printWindow.close();
|
||||||
|
setTimeout(() => printWindow.print(), 500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF download failed', err);
|
||||||
|
} finally {
|
||||||
|
setDownloadingPdf(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
|
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
|
||||||
<style>{`
|
|
||||||
@media print {
|
|
||||||
body * { visibility: hidden; }
|
|
||||||
.report-container, .report-container * { visibility: visible; }
|
|
||||||
.report-container { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; }
|
|
||||||
.no-print { display: none !important; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={safeTab}
|
value={safeTab}
|
||||||
|
|
@ -323,8 +365,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
{activeTabHasData && (
|
{activeTabHasData && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={downloadingPdf ? <CircularProgress size={16} /> : <DownloadIcon />}
|
||||||
onClick={() => window.print()}
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={downloadingPdf}
|
||||||
sx={{ ml: 2, whiteSpace: 'nowrap' }}
|
sx={{ ml: 2, whiteSpace: 'nowrap' }}
|
||||||
>
|
>
|
||||||
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
|
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
|
||||||
|
|
@ -361,7 +404,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<DailySection installationId={installationId} onHasData={setDailyHasData} />
|
<DailySection installationId={installationId} onHasData={setDailyHasData} onPeriodChange={(date: string) => setReportPeriod({ start: date, end: date })} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<WeeklySection
|
<WeeklySection
|
||||||
|
|
@ -373,6 +416,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onHasData={setWeeklyHasData}
|
onHasData={setWeeklyHasData}
|
||||||
|
onPeriodChange={(start, end) => setReportPeriod({ start, end })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
|
|
@ -384,6 +428,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
onGenerate={handleGenerateMonthly}
|
onGenerate={handleGenerateMonthly}
|
||||||
selectedIdx={selectedMonthlyIdx}
|
selectedIdx={selectedMonthlyIdx}
|
||||||
onSelectedIdxChange={setSelectedMonthlyIdx}
|
onSelectedIdxChange={setSelectedMonthlyIdx}
|
||||||
|
onPeriodChange={(r: MonthlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
|
|
@ -395,6 +440,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
onGenerate={handleGenerateYearly}
|
onGenerate={handleGenerateYearly}
|
||||||
selectedIdx={selectedYearlyIdx}
|
selectedIdx={selectedYearlyIdx}
|
||||||
onSelectedIdxChange={setSelectedYearlyIdx}
|
onSelectedIdxChange={setSelectedYearlyIdx}
|
||||||
|
onPeriodChange={(r: YearlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -407,8 +453,8 @@ interface WeeklySectionHandle {
|
||||||
regenerate: () => void;
|
regenerate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void }>(
|
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void; onPeriodChange?: (start: string, end: string) => void }>(
|
||||||
({ installationId, latestMonthlyPeriodEnd, onHasData }, ref) => {
|
({ installationId, latestMonthlyPeriodEnd, onHasData, onPeriodChange }, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
|
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -427,6 +473,7 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
|
||||||
});
|
});
|
||||||
setReport(res.data);
|
setReport(res.data);
|
||||||
onHasData?.(true);
|
onHasData?.(true);
|
||||||
|
onPeriodChange?.(res.data.periodStart, res.data.periodEnd);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg =
|
const msg =
|
||||||
err.response?.data ||
|
err.response?.data ||
|
||||||
|
|
@ -811,7 +858,8 @@ function MonthlySection({
|
||||||
generating,
|
generating,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
selectedIdx,
|
selectedIdx,
|
||||||
onSelectedIdxChange
|
onSelectedIdxChange,
|
||||||
|
onPeriodChange
|
||||||
}: {
|
}: {
|
||||||
installationId: number;
|
installationId: number;
|
||||||
reports: MonthlyReport[];
|
reports: MonthlyReport[];
|
||||||
|
|
@ -820,6 +868,7 @@ function MonthlySection({
|
||||||
onGenerate: (year: number, month: number) => void;
|
onGenerate: (year: number, month: number) => void;
|
||||||
selectedIdx: number;
|
selectedIdx: number;
|
||||||
onSelectedIdxChange: (idx: number) => void;
|
onSelectedIdxChange: (idx: number) => void;
|
||||||
|
onPeriodChange?: (report: MonthlyReport) => void;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
|
@ -871,6 +920,7 @@ function MonthlySection({
|
||||||
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
|
||||||
controlledIdx={selectedIdx}
|
controlledIdx={selectedIdx}
|
||||||
onIdxChange={onSelectedIdxChange}
|
onIdxChange={onSelectedIdxChange}
|
||||||
|
onPeriodChange={onPeriodChange}
|
||||||
/>
|
/>
|
||||||
) : pendingMonths.length === 0 ? (
|
) : pendingMonths.length === 0 ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
|
|
@ -892,7 +942,8 @@ function YearlySection({
|
||||||
generating,
|
generating,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
selectedIdx,
|
selectedIdx,
|
||||||
onSelectedIdxChange
|
onSelectedIdxChange,
|
||||||
|
onPeriodChange
|
||||||
}: {
|
}: {
|
||||||
installationId: number;
|
installationId: number;
|
||||||
reports: YearlyReport[];
|
reports: YearlyReport[];
|
||||||
|
|
@ -901,6 +952,7 @@ function YearlySection({
|
||||||
onGenerate: (year: number) => void;
|
onGenerate: (year: number) => void;
|
||||||
selectedIdx: number;
|
selectedIdx: number;
|
||||||
onSelectedIdxChange: (idx: number) => void;
|
onSelectedIdxChange: (idx: number) => void;
|
||||||
|
onPeriodChange?: (report: YearlyReport) => void;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
|
@ -952,6 +1004,7 @@ function YearlySection({
|
||||||
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
|
||||||
controlledIdx={selectedIdx}
|
controlledIdx={selectedIdx}
|
||||||
onIdxChange={onSelectedIdxChange}
|
onIdxChange={onSelectedIdxChange}
|
||||||
|
onPeriodChange={onPeriodChange}
|
||||||
/>
|
/>
|
||||||
) : pendingYears.length === 0 ? (
|
) : pendingYears.length === 0 ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
|
|
@ -975,7 +1028,8 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
sendEndpoint,
|
sendEndpoint,
|
||||||
sendParamsFn,
|
sendParamsFn,
|
||||||
controlledIdx,
|
controlledIdx,
|
||||||
onIdxChange
|
onIdxChange,
|
||||||
|
onPeriodChange
|
||||||
}: {
|
}: {
|
||||||
reports: T[];
|
reports: T[];
|
||||||
type: 'monthly' | 'yearly';
|
type: 'monthly' | 'yearly';
|
||||||
|
|
@ -986,6 +1040,7 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
sendParamsFn: (r: T) => object;
|
sendParamsFn: (r: T) => object;
|
||||||
controlledIdx?: number;
|
controlledIdx?: number;
|
||||||
onIdxChange?: (idx: number) => void;
|
onIdxChange?: (idx: number) => void;
|
||||||
|
onPeriodChange?: (report: T) => void;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [internalIdx, setInternalIdx] = useState(0);
|
const [internalIdx, setInternalIdx] = useState(0);
|
||||||
|
|
@ -993,8 +1048,16 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
const handleIdxChange = (idx: number) => {
|
const handleIdxChange = (idx: number) => {
|
||||||
setInternalIdx(idx);
|
setInternalIdx(idx);
|
||||||
onIdxChange?.(idx);
|
onIdxChange?.(idx);
|
||||||
|
if (reports[idx]) onPeriodChange?.(reports[idx]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Report initial period on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (reports.length > 0 && reports[selectedIdx]) {
|
||||||
|
onPeriodChange?.(reports[selectedIdx]);
|
||||||
|
}
|
||||||
|
}, [reports.length]);
|
||||||
|
|
||||||
if (reports.length === 0) {
|
if (reports.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ export interface I_Installation extends I_S3Credentials {
|
||||||
batteryClusterNumber: number;
|
batteryClusterNumber: number;
|
||||||
batteryNumber: number;
|
batteryNumber: number;
|
||||||
batterySerialNumbers: string;
|
batterySerialNumbers: string;
|
||||||
|
pvStringsPerInverter: string;
|
||||||
|
installationModel: string;
|
||||||
|
externalEms: string;
|
||||||
|
|
||||||
parentId: number;
|
parentId: number;
|
||||||
s3WriteKey: string;
|
s3WriteKey: string;
|
||||||
s3WriteSecret: string;
|
s3WriteSecret: string;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,24 @@
|
||||||
"live": "Live Daten",
|
"live": "Live Daten",
|
||||||
"deleteInstallation": "Installation löschen",
|
"deleteInstallation": "Installation löschen",
|
||||||
"confirmDeleteInstallation": "Möchten Sie diese Installation löschen?",
|
"confirmDeleteInstallation": "Möchten Sie diese Installation löschen?",
|
||||||
|
"installationModel": "Installationsmodell",
|
||||||
|
"externalEms": "Externes EMS",
|
||||||
|
"externalEmsOther": "Externes EMS (angeben)",
|
||||||
|
"emsNo": "Nein",
|
||||||
|
"emsOther": "Andere",
|
||||||
|
"generalInfo": "Allgemeine Informationen",
|
||||||
|
"installationSetup": "Installationseinrichtung",
|
||||||
|
"selectModel": "Modell auswählen...",
|
||||||
|
"inverterN": "Wechselrichter {n}",
|
||||||
|
"clusterN": "Cluster {n}",
|
||||||
|
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} Cluster, {filledBat}/{totalBat} Batterien",
|
||||||
|
"batteriesSummary": "{filled}/{total} Batterien",
|
||||||
|
"inverterNSerialNumber": "Wechselrichter {n} Seriennummer",
|
||||||
|
"dataloggerNSerialNumber": "Datenlogger {n} Seriennummer",
|
||||||
|
"pvStringsOnInverterN": "Anzahl PV-Strings an Wechselrichter {n}",
|
||||||
|
"batteryNSerialNumber": "Batterie {n} Seriennummer",
|
||||||
|
"adminSection": "Admin",
|
||||||
|
"confirmPresetSwitch": "Der Wechsel zu einer kleineren Konfiguration entfernt einige Batterie-Seriennummern. Fortfahren?",
|
||||||
"bucketLabel": "Bucket",
|
"bucketLabel": "Bucket",
|
||||||
"deleteInstallationWarning": "Bitte notieren Sie den Bucket-Namen oben. Das Löschen der S3-Daten kann mehrere Minuten dauern. Überprüfen Sie nach dem Löschen in Exoscale, ob der Bucket entfernt wurde. Falls nicht, leeren und löschen Sie den Bucket manuell.",
|
"deleteInstallationWarning": "Bitte notieren Sie den Bucket-Namen oben. Das Löschen der S3-Daten kann mehrere Minuten dauern. Überprüfen Sie nach dem Löschen in Exoscale, ob der Bucket entfernt wurde. Falls nicht, leeren und löschen Sie den Bucket manuell.",
|
||||||
"errorOccured": "Ein Fehler ist aufgetreten",
|
"errorOccured": "Ein Fehler ist aufgetreten",
|
||||||
|
|
@ -85,6 +103,7 @@
|
||||||
"noUsersWithDirectAccessToThis": "Keine Benutzer mit direktem Zugriff",
|
"noUsersWithDirectAccessToThis": "Keine Benutzer mit direktem Zugriff",
|
||||||
"selectUsers": "Benutzer auswählen",
|
"selectUsers": "Benutzer auswählen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"continue": "Fortfahren",
|
||||||
"addNewFolder": "Neuen Ordner hinzufügen",
|
"addNewFolder": "Neuen Ordner hinzufügen",
|
||||||
"addNewInstallation": "Neue Installation hinzufügen",
|
"addNewInstallation": "Neue Installation hinzufügen",
|
||||||
"deleteFolder": "Ordner löschen",
|
"deleteFolder": "Ordner löschen",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,24 @@
|
||||||
"live": "Live View",
|
"live": "Live View",
|
||||||
"deleteInstallation": "Delete Installation",
|
"deleteInstallation": "Delete Installation",
|
||||||
"confirmDeleteInstallation": "Do you want to delete this installation?",
|
"confirmDeleteInstallation": "Do you want to delete this installation?",
|
||||||
|
"installationModel": "Installation Model",
|
||||||
|
"externalEms": "External EMS",
|
||||||
|
"externalEmsOther": "External EMS (specify)",
|
||||||
|
"emsNo": "No",
|
||||||
|
"emsOther": "Other",
|
||||||
|
"generalInfo": "General Info",
|
||||||
|
"installationSetup": "Installation Setup",
|
||||||
|
"selectModel": "Select model...",
|
||||||
|
"inverterN": "Inverter {n}",
|
||||||
|
"clusterN": "Cluster {n}",
|
||||||
|
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries",
|
||||||
|
"batteriesSummary": "{filled}/{total} batteries",
|
||||||
|
"inverterNSerialNumber": "Inverter {n} Serial Number",
|
||||||
|
"dataloggerNSerialNumber": "Datalogger {n} Serial Number",
|
||||||
|
"pvStringsOnInverterN": "Number of PV Strings on Inverter {n}",
|
||||||
|
"batteryNSerialNumber": "Battery {n} Serial Number",
|
||||||
|
"adminSection": "Admin",
|
||||||
|
"confirmPresetSwitch": "Switching to a smaller configuration will remove some battery serial number entries. Continue?",
|
||||||
"bucketLabel": "Bucket",
|
"bucketLabel": "Bucket",
|
||||||
"deleteInstallationWarning": "Please note the bucket name above. Purging S3 data may take several minutes. After deletion, verify in Exoscale that the bucket has been removed. If not, purge and delete the bucket manually.",
|
"deleteInstallationWarning": "Please note the bucket name above. Purging S3 data may take several minutes. After deletion, verify in Exoscale that the bucket has been removed. If not, purge and delete the bucket manually.",
|
||||||
"errorOccured": "An error has occurred",
|
"errorOccured": "An error has occurred",
|
||||||
|
|
@ -67,6 +85,7 @@
|
||||||
"noUsersWithDirectAccessToThis": "No users with direct access to this ",
|
"noUsersWithDirectAccessToThis": "No users with direct access to this ",
|
||||||
"selectUsers": "Select Users",
|
"selectUsers": "Select Users",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"continue": "Continue",
|
||||||
"addNewFolder": "Add new Folder",
|
"addNewFolder": "Add new Folder",
|
||||||
"addNewInstallation": "Add new Installation",
|
"addNewInstallation": "Add new Installation",
|
||||||
"deleteFolder": "Delete Folder",
|
"deleteFolder": "Delete Folder",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,24 @@
|
||||||
"live": "Diffusion en direct",
|
"live": "Diffusion en direct",
|
||||||
"deleteInstallation": "Supprimer l'installation",
|
"deleteInstallation": "Supprimer l'installation",
|
||||||
"confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?",
|
"confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?",
|
||||||
|
"installationModel": "Modèle d'installation",
|
||||||
|
"externalEms": "EMS externe",
|
||||||
|
"externalEmsOther": "EMS externe (préciser)",
|
||||||
|
"emsNo": "Non",
|
||||||
|
"emsOther": "Autre",
|
||||||
|
"generalInfo": "Informations générales",
|
||||||
|
"installationSetup": "Configuration de l'installation",
|
||||||
|
"selectModel": "Sélectionner le modèle...",
|
||||||
|
"inverterN": "Onduleur {n}",
|
||||||
|
"clusterN": "Cluster {n}",
|
||||||
|
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries",
|
||||||
|
"batteriesSummary": "{filled}/{total} batteries",
|
||||||
|
"inverterNSerialNumber": "Numéro de série onduleur {n}",
|
||||||
|
"dataloggerNSerialNumber": "Numéro de série datalogger {n}",
|
||||||
|
"pvStringsOnInverterN": "Nombre de chaînes PV sur onduleur {n}",
|
||||||
|
"batteryNSerialNumber": "Numéro de série batterie {n}",
|
||||||
|
"adminSection": "Admin",
|
||||||
|
"confirmPresetSwitch": "Le passage à une configuration plus petite supprimera certains numéros de série de batteries. Continuer ?",
|
||||||
"bucketLabel": "Bucket",
|
"bucketLabel": "Bucket",
|
||||||
"deleteInstallationWarning": "Veuillez noter le nom du bucket ci-dessus. La purge des données S3 peut prendre plusieurs minutes. Après la suppression, vérifiez dans Exoscale que le bucket a bien été supprimé. Sinon, purgez et supprimez le bucket manuellement.",
|
"deleteInstallationWarning": "Veuillez noter le nom du bucket ci-dessus. La purge des données S3 peut prendre plusieurs minutes. Après la suppression, vérifiez dans Exoscale que le bucket a bien été supprimé. Sinon, purgez et supprimez le bucket manuellement.",
|
||||||
"errorOccured": "Une erreur s'est produite",
|
"errorOccured": "Une erreur s'est produite",
|
||||||
|
|
@ -79,6 +97,7 @@
|
||||||
"noUsersWithDirectAccessToThis": "Aucun utilisateur ayant un accès direct",
|
"noUsersWithDirectAccessToThis": "Aucun utilisateur ayant un accès direct",
|
||||||
"selectUsers": "Sélectionnez les utilisateurs",
|
"selectUsers": "Sélectionnez les utilisateurs",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
"continue": "Continuer",
|
||||||
"addNewFolder": "Ajouter un nouveau dossier",
|
"addNewFolder": "Ajouter un nouveau dossier",
|
||||||
"addNewInstallation": "Ajouter une nouvelle installation",
|
"addNewInstallation": "Ajouter une nouvelle installation",
|
||||||
"deleteFolder": "Supprimer le dossier",
|
"deleteFolder": "Supprimer le dossier",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,24 @@
|
||||||
"live": "Vista in diretta",
|
"live": "Vista in diretta",
|
||||||
"deleteInstallation": "Elimina installazione",
|
"deleteInstallation": "Elimina installazione",
|
||||||
"confirmDeleteInstallation": "Vuoi eliminare questa installazione?",
|
"confirmDeleteInstallation": "Vuoi eliminare questa installazione?",
|
||||||
|
"installationModel": "Modello di installazione",
|
||||||
|
"externalEms": "EMS esterno",
|
||||||
|
"externalEmsOther": "EMS esterno (specificare)",
|
||||||
|
"emsNo": "No",
|
||||||
|
"emsOther": "Altro",
|
||||||
|
"generalInfo": "Informazioni generali",
|
||||||
|
"installationSetup": "Configurazione installazione",
|
||||||
|
"selectModel": "Seleziona modello...",
|
||||||
|
"inverterN": "Inverter {n}",
|
||||||
|
"clusterN": "Cluster {n}",
|
||||||
|
"clustersBatteriesSummary": "{filledClusters}/{totalClusters} cluster, {filledBat}/{totalBat} batterie",
|
||||||
|
"batteriesSummary": "{filled}/{total} batterie",
|
||||||
|
"inverterNSerialNumber": "Numero di serie inverter {n}",
|
||||||
|
"dataloggerNSerialNumber": "Numero di serie datalogger {n}",
|
||||||
|
"pvStringsOnInverterN": "Numero di stringhe PV sull'inverter {n}",
|
||||||
|
"batteryNSerialNumber": "Numero di serie batteria {n}",
|
||||||
|
"adminSection": "Admin",
|
||||||
|
"confirmPresetSwitch": "Il passaggio a una configurazione più piccola rimuoverà alcuni numeri di serie delle batterie. Continuare?",
|
||||||
"bucketLabel": "Bucket",
|
"bucketLabel": "Bucket",
|
||||||
"deleteInstallationWarning": "Prendi nota del nome del bucket qui sopra. L'eliminazione dei dati S3 potrebbe richiedere diversi minuti. Dopo l'eliminazione, verifica in Exoscale che il bucket sia stato rimosso. In caso contrario, svuota ed elimina il bucket manualmente.",
|
"deleteInstallationWarning": "Prendi nota del nome del bucket qui sopra. L'eliminazione dei dati S3 potrebbe richiedere diversi minuti. Dopo l'eliminazione, verifica in Exoscale che il bucket sia stato rimosso. In caso contrario, svuota ed elimina il bucket manualmente.",
|
||||||
"errorOccured": "Si è verificato un errore",
|
"errorOccured": "Si è verificato un errore",
|
||||||
|
|
@ -67,6 +85,7 @@
|
||||||
"noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo",
|
"noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo",
|
||||||
"selectUsers": "Seleziona utenti",
|
"selectUsers": "Seleziona utenti",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
|
"continue": "Continua",
|
||||||
"addNewFolder": "Aggiungi nuova cartella",
|
"addNewFolder": "Aggiungi nuova cartella",
|
||||||
"addNewInstallation": "Aggiungi nuova installazione",
|
"addNewInstallation": "Aggiungi nuova installazione",
|
||||||
"deleteFolder": "Elimina cartella",
|
"deleteFolder": "Elimina cartella",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue