Merge branch 'main' into sinexcel_multiinveters_configurtaion

# Conflicts:
#	typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx
This commit is contained in:
Yinyin Liu 2026-03-24 10:49:25 +01:00
commit 8578b42503
16 changed files with 1073 additions and 316 deletions

View File

@ -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))]
public async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)

View File

@ -49,6 +49,9 @@ public class Installation : TreeNode
public int BatteryClusterNumber { get; set; } = 0;
public int BatteryNumber { get; set; } = 0;
public string BatterySerialNumbers { get; set; } = "";
public string PvStringsPerInverter { get; set; } = "";
public string InstallationModel { get; set; } = "";
public string ExternalEms { get; set; } = "No";
[Ignore]
public String OrderNumbers { get; set; }

View File

@ -130,6 +130,11 @@ public static partial class Db
fileConnection.CreateTable<TicketAiDiagnosis>();
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 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

View File

@ -1,8 +1,12 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Autocomplete,
Box,
CardContent,
Chip,
CircularProgress,
Container,
FormControl,
@ -16,10 +20,11 @@ import {
Typography,
useTheme
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import Button from '@mui/material/Button';
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_Installation } from '../../../interfaces/InstallationTypes';
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
@ -28,6 +33,16 @@ import routes from '../../../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
import { UserType } from '../../../interfaces/UserTypes';
import axiosConfig from '../../../Resources/axiosConfig';
import {
INSTALLATION_PRESETS,
PresetConfig,
BatterySnTree,
parseBatterySnTree,
buildEmptyTree,
remapTree,
computeFlatValues,
wouldLoseData,
} from './installationSetupUtils';
interface InformationSodistorehomeProps {
values: I_Installation;
@ -43,35 +58,205 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const context = useContext(UserContext);
const { currentUser } = context;
const theme = useTheme();
const intl = useIntl();
const [formValues, setFormValues] = useState(props.values);
const requiredFields = ['name', 'region', 'location', 'country'];
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
useState(false);
const [pendingPreset, setPendingPreset] = useState<string | null>(null);
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 = [
{ id: 3, name: 'Growatt' },
{ 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
useEffect(() => {
if (props.values.batteryNumber) {
setBatteryNumber(props.values.batteryNumber);
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
if (presetConfig) {
return parseBatterySnTree(props.values.batterySerialNumbers || '', presetConfig);
}
if (props.values.batterySerialNumbers) {
const serialNumbers = props.values.batterySerialNumbers
.split(',')
.filter((sn) => sn.trim() !== '');
setBatterySerialNumbers(serialNumbers);
return [];
});
const [inverterSerialNumbers, setInverterSerialNumbers] = useState<string[]>(() =>
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 {
@ -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 = () => {
setLoading(true);
setError(false);
@ -292,6 +429,75 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</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">
<Grid
container
@ -310,14 +516,13 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
noValidate
autoComplete="off"
>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
<FormattedMessage id="generalInfo" defaultMessage="General Info" />
</Typography>
<div>
<TextField
label={
<FormattedMessage
id="installation_name"
defaultMessage="Installation Name"
/>
}
label={<FormattedMessage id="installation_name" defaultMessage="Installation Name" />}
name="name"
value={formValues.name}
onChange={handleChange}
@ -328,9 +533,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div>
<div>
<TextField
label={
<FormattedMessage id="region" defaultMessage="Region" />
}
label={<FormattedMessage id="region" defaultMessage="Region" />}
name="region"
value={formValues.region}
onChange={handleChange}
@ -343,12 +546,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div>
<div>
<TextField
label={
<FormattedMessage
id="location"
defaultMessage="Location"
/>
}
label={<FormattedMessage id="location" defaultMessage="Location" />}
name="location"
value={formValues.location}
onChange={handleChange}
@ -361,9 +559,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div>
<div>
<TextField
label={
<FormattedMessage id="country" defaultMessage="Country" />
}
label={<FormattedMessage id="country" defaultMessage="Country" />}
name="country"
value={formValues.country}
onChange={handleChange}
@ -375,87 +571,22 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
/>
</div>
<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: (
<>
{loadingProviders ? (
<CircularProgress size={20} />
) : null}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
/>
</div>
{canEdit && (
{formValues.installationModel && (
<div>
<TextField
label={
<FormattedMessage id="vpnip" defaultMessage="VPN IP" />
}
name="vpnIp"
value={formValues.vpnIp}
onChange={handleChange}
label={<FormattedMessage id="installationModel" defaultMessage="Installation Model" />}
value={formValues.installationModel}
variant="outlined"
fullWidth
inputProps={{ readOnly: true }}
/>
</div>
)}
<div>
<FormControl
fullWidth
sx={{
marginLeft: 1,
marginTop: 1,
marginBottom: 1,
width: 440
}}
>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="DeviceType"
defaultMessage="Device Type"
/>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
</InputLabel>
<Select
name="device"
@ -472,125 +603,84 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</FormControl>
</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>
<TextField
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>
))}
{loadingProviders ? <CircularProgress size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
</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>
<TextField
label={
<FormattedMessage
id="information"
defaultMessage="Information"
/>
}
label={<FormattedMessage id="information" defaultMessage="Information" />}
name="information"
value={formValues.information}
onChange={handleChange}
@ -600,35 +690,202 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
/>
</div>
{canEdit && (
{(canEdit || isPartner) && (
<>
<Typography variant="h6" sx={{ mt: 4, mb: 2, fontWeight: 'bold' }}>
<FormattedMessage id="installationSetup" defaultMessage="Installation Setup" />
</Typography>
<div>
<TextField
label="S3 Bucket Name"
name="s3writesecretkey"
value={
formValues.s3BucketId +
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
}
variant="outlined"
fullWidth
/>
<FormControl sx={{ m: 1, width: '50ch' }}>
<InputLabel
shrink
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
>
Installation Model
</InputLabel>
<Select
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>
<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>
<TextField
label="S3 Write Key"
name="s3writesecretkey"
value={formValues.s3WriteKey}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label="S3 Write Secret Key"
name="s3writesecretkey"
value={formValues.s3WriteSecret}
variant="outlined"
fullWidth

View File

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

View File

@ -97,10 +97,12 @@ function getCurrentWeekDays(currentMonday: Date): Date[] {
export default function DailySection({
installationId,
onHasData
onHasData,
onPeriodChange
}: {
installationId: number;
onHasData?: (hasData: boolean) => void;
onPeriodChange?: (date: string) => void;
}) {
const intl = useIntl();
const currentMonday = useMemo(() => getCurrentMonday(), []);
@ -113,7 +115,11 @@ export default function DailySection({
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
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 [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [loadingWeek, setLoadingWeek] = useState(false);
@ -174,6 +180,7 @@ export default function DailySection({
const handleStripSelect = (date: string) => {
setSelectedDate(date);
setNoData(false);
onPeriodChange?.(date);
};
const dt = new Date(selectedDate + 'T00:00:00');

View File

@ -104,17 +104,19 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
inverterNumber: inverterNum,
batteriesCountPerInverter: batteriesPerInverter,
batteriesCount: props.values.Config.BatteriesCount,
clusterNumber: props.values.Config.ClusterNumber || 1,
clusterNumber: props.values.Config.ClusterNumber ?? 1,
PvNumber: props.values.Config.PvNumber ?? 0,
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
startTimeChargeandDischargeDayandTime:
props.values.Config?.StartTimeChargeandDischargeDayandTime
? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate()
: null,
stopTimeChargeandDischargeDayandTime:
props.values.Config?.StopTimeChargeandDischargeDayandTime
? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate()
: null,
startTimeChargeandDischargeDayandTime: (() => {
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
const parsed = raw ? dayjs(raw) : null;
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
stopTimeChargeandDischargeDayandTime: (() => {
const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime;
const parsed = raw ? dayjs(raw) : null;
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
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 = () => {
if (formValues.startTimeChargeandDischargeDayandTime &&
if (device === 4 &&
OperatingPriorityOptions[formValues.operatingPriority] === 'BatteryPriority' &&
formValues.startTimeChargeandDischargeDayandTime &&
formValues.stopTimeChargeandDischargeDayandTime) {
const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours();
const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes();

View File

@ -17,6 +17,7 @@ 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';
interface SodistorehomeInstallationFormPros {
cancel: () => void;
@ -33,8 +34,10 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
location: '',
country: '',
vpnIp: '',
installationModel: '',
externalEms: 'No',
});
const requiredFields = ['name', 'location', 'country', 'vpnIp'];
const requiredFields = ['name', 'location', 'country', 'vpnIp', 'installationModel'];
const DeviceTypes = [
{ id: 3, name: 'Growatt' },
@ -171,6 +174,42 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
/>
</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>
<FormControl
fullWidth

View File

@ -236,6 +236,8 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const [regenerating, setRegenerating] = useState(false);
const [dailyHasData, setDailyHasData] = 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 fetchReportData = () => {
@ -302,16 +304,56 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
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 (
<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">
<Tabs
value={safeTab}
@ -323,8 +365,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{activeTabHasData && (
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => window.print()}
startIcon={downloadingPdf ? <CircularProgress size={16} /> : <DownloadIcon />}
onClick={handleDownloadPdf}
disabled={downloadingPdf}
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
@ -361,7 +404,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Box>
<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 sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
<WeeklySection
@ -373,6 +416,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
: null
}
onHasData={setWeeklyHasData}
onPeriodChange={(start, end) => setReportPeriod({ start, end })}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
@ -384,6 +428,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
onGenerate={handleGenerateMonthly}
selectedIdx={selectedMonthlyIdx}
onSelectedIdxChange={setSelectedMonthlyIdx}
onPeriodChange={(r: MonthlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month })}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
@ -395,6 +440,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
onGenerate={handleGenerateYearly}
selectedIdx={selectedYearlyIdx}
onSelectedIdxChange={setSelectedYearlyIdx}
onPeriodChange={(r: YearlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year })}
/>
</Box>
</Box>
@ -407,8 +453,8 @@ interface WeeklySectionHandle {
regenerate: () => void;
}
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void }>(
({ installationId, latestMonthlyPeriodEnd, onHasData }, ref) => {
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void; onPeriodChange?: (start: string, end: string) => void }>(
({ installationId, latestMonthlyPeriodEnd, onHasData, onPeriodChange }, ref) => {
const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true);
@ -427,6 +473,7 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
});
setReport(res.data);
onHasData?.(true);
onPeriodChange?.(res.data.periodStart, res.data.periodEnd);
} catch (err: any) {
const msg =
err.response?.data ||
@ -811,7 +858,8 @@ function MonthlySection({
generating,
onGenerate,
selectedIdx,
onSelectedIdxChange
onSelectedIdxChange,
onPeriodChange
}: {
installationId: number;
reports: MonthlyReport[];
@ -820,6 +868,7 @@ function MonthlySection({
onGenerate: (year: number, month: number) => void;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
onPeriodChange?: (report: MonthlyReport) => void;
}) {
const intl = useIntl();
@ -871,6 +920,7 @@ function MonthlySection({
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
controlledIdx={selectedIdx}
onIdxChange={onSelectedIdxChange}
onPeriodChange={onPeriodChange}
/>
) : pendingMonths.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
@ -892,7 +942,8 @@ function YearlySection({
generating,
onGenerate,
selectedIdx,
onSelectedIdxChange
onSelectedIdxChange,
onPeriodChange
}: {
installationId: number;
reports: YearlyReport[];
@ -901,6 +952,7 @@ function YearlySection({
onGenerate: (year: number) => void;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
onPeriodChange?: (report: YearlyReport) => void;
}) {
const intl = useIntl();
@ -952,6 +1004,7 @@ function YearlySection({
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
controlledIdx={selectedIdx}
onIdxChange={onSelectedIdxChange}
onPeriodChange={onPeriodChange}
/>
) : pendingYears.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
@ -975,7 +1028,8 @@ function AggregatedSection<T extends ReportSummary>({
sendEndpoint,
sendParamsFn,
controlledIdx,
onIdxChange
onIdxChange,
onPeriodChange
}: {
reports: T[];
type: 'monthly' | 'yearly';
@ -986,6 +1040,7 @@ function AggregatedSection<T extends ReportSummary>({
sendParamsFn: (r: T) => object;
controlledIdx?: number;
onIdxChange?: (idx: number) => void;
onPeriodChange?: (report: T) => void;
}) {
const intl = useIntl();
const [internalIdx, setInternalIdx] = useState(0);
@ -993,8 +1048,16 @@ function AggregatedSection<T extends ReportSummary>({
const handleIdxChange = (idx: number) => {
setInternalIdx(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) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>

View File

@ -18,6 +18,10 @@ export interface I_Installation extends I_S3Credentials {
batteryClusterNumber: number;
batteryNumber: number;
batterySerialNumbers: string;
pvStringsPerInverter: string;
installationModel: string;
externalEms: string;
parentId: number;
s3WriteKey: string;
s3WriteSecret: string;

View File

@ -73,6 +73,24 @@
"live": "Live Daten",
"deleteInstallation": "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",
"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",
@ -85,6 +103,7 @@
"noUsersWithDirectAccessToThis": "Keine Benutzer mit direktem Zugriff",
"selectUsers": "Benutzer auswählen",
"cancel": "Abbrechen",
"continue": "Fortfahren",
"addNewFolder": "Neuen Ordner hinzufügen",
"addNewInstallation": "Neue Installation hinzufügen",
"deleteFolder": "Ordner löschen",

View File

@ -55,6 +55,24 @@
"live": "Live View",
"deleteInstallation": "Delete 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",
"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",
@ -67,6 +85,7 @@
"noUsersWithDirectAccessToThis": "No users with direct access to this ",
"selectUsers": "Select Users",
"cancel": "Cancel",
"continue": "Continue",
"addNewFolder": "Add new Folder",
"addNewInstallation": "Add new Installation",
"deleteFolder": "Delete Folder",

View File

@ -67,6 +67,24 @@
"live": "Diffusion en direct",
"deleteInstallation": "Supprimer l'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",
"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",
@ -79,6 +97,7 @@
"noUsersWithDirectAccessToThis": "Aucun utilisateur ayant un accès direct",
"selectUsers": "Sélectionnez les utilisateurs",
"cancel": "Annuler",
"continue": "Continuer",
"addNewFolder": "Ajouter un nouveau dossier",
"addNewInstallation": "Ajouter une nouvelle installation",
"deleteFolder": "Supprimer le dossier",

View File

@ -55,6 +55,24 @@
"live": "Vista in diretta",
"deleteInstallation": "Elimina 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",
"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",
@ -67,6 +85,7 @@
"noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo",
"selectUsers": "Seleziona utenti",
"cancel": "Annulla",
"continue": "Continua",
"addNewFolder": "Aggiungi nuova cartella",
"addNewInstallation": "Aggiungi nuova installazione",
"deleteFolder": "Elimina cartella",