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))] [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)

View File

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

View File

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

View File

@ -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
<div> 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 <TextField
label={ {...params}
<FormattedMessage label={<FormattedMessage id="networkProvider" defaultMessage="Network Provider" />}
id="installationSerialNumber"
defaultMessage="Installation Serial Number"
/>
}
name="serialNumber"
value={formValues.serialNumber}
onChange={handleChange}
variant="outlined" variant="outlined"
fullWidth fullWidth
inputProps={{ readOnly: !canEdit }} InputProps={{
...params.InputProps,
endAdornment: (
<>
{loadingProviders ? <CircularProgress size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/> />
</div> )}
/>
</div>
<div> <div>
<TextField <FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
label={ <InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage <FormattedMessage id="externalEms" defaultMessage="External EMS" />
id="inverterSN" </InputLabel>
defaultMessage="Inverter Serial Number" <Select
/> name="externalEms"
} value={
name="inverterSN" ['No', 'Solar Manager', 'Smart Fox', 'Loxone'].includes(formValues.externalEms || 'No')
value={formValues.inverterSN} ? (formValues.externalEms || 'No')
onChange={handleChange} : 'Other'
variant="outlined" }
fullWidth onChange={(e) => {
inputProps={{ readOnly: !canEdit }} const val = e.target.value as string;
/> setFormValues({ ...formValues, externalEms: val });
</div> }}
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>
<div> {formValues.externalEms &&
<TextField !['No', 'Solar Manager', 'Smart Fox', 'Loxone'].includes(formValues.externalEms) && (
label={ <div>
<FormattedMessage <TextField
id="dataloggerSN" label={<FormattedMessage id="externalEmsOther" defaultMessage="External EMS (specify)" />}
defaultMessage="Datalogger Serial Number" name="externalEms"
/> value={formValues.externalEms === 'Other' ? '' : formValues.externalEms}
} onChange={handleChange}
name="dataloggerSN" variant="outlined"
value={formValues.dataloggerSN} fullWidth
onChange={handleChange} inputProps={{ readOnly: !canEdit && !isPartner }}
variant="outlined" />
fullWidth </div>
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>
<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

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({ 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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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