officially bring in new configurtaion with dynamic pricing for sodistore home and pro

This commit is contained in:
Yinyin Liu 2026-05-06 15:19:55 +02:00
parent b0bb332482
commit 795e77d304
2 changed files with 5 additions and 733 deletions

View File

@ -24,12 +24,7 @@ import { TimeSpan, UnixTime } from '../../../dataCache/time';
import { fetchDataJson } from '../Installations/fetchData'; import { fetchDataJson } from '../Installations/fetchData';
import { FetchResult } from '../../../dataCache/dataCache'; import { FetchResult } from '../../../dataCache/dataCache';
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome'; import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
import SodistoreHomeConfigurationV2 from './SodistoreHomeConfigurationV2'; import SodistoreHomeConfigurationV2 from './SodistoreHomeConfigurationV2';
// Pilot installations using the new per-cluster Configuration page (V2).
// All other installations keep using the original SodistoreHomeConfiguration (V1).
const CONFIG_V2_INSTALLATION_IDS = new Set<number>([790, 839]);
import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview'; import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport'; import WeeklyReport from './WeeklyReport';
@ -604,19 +599,11 @@ function SodioHomeInstallation(props: singleInstallationProps) {
<Route <Route
path={routes.configuration} path={routes.configuration}
element={ element={
CONFIG_V2_INSTALLATION_IDS.has(props.current_installation.id) ? ( <SodistoreHomeConfigurationV2
<SodistoreHomeConfigurationV2 values={values}
values={values} id={props.current_installation.id}
id={props.current_installation.id} installation={props.current_installation}
installation={props.current_installation} />
/>
) : (
<SodistoreHomeConfiguration
values={values}
id={props.current_installation.id}
installation={props.current_installation}
/>
)
} }
/> />
)} )}

View File

@ -1,715 +0,0 @@
import { ConfigurationValues, JSONRecordData } from '../Log/graph.util';
import {
Alert,
Box,
CardContent,
CircularProgress,
Container,
FormControl,
Grid,
IconButton,
InputLabel,
Modal,
Select,
TextField,
Typography,
useTheme
} from '@mui/material';
import React, { useContext, useState, useEffect } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import MenuItem from '@mui/material/MenuItem';
import axiosConfig from '../../../Resources/axiosConfig';
import { UserContext } from '../../../contexts/userContext';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import {DateTimePicker } from '@mui/x-date-pickers';
import dayjs from 'dayjs';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
interface SodistoreHomeConfigurationProps {
values: JSONRecordData;
id: number;
installation: I_Installation;
}
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
const intl = useIntl();
if (props.values === null) {
return null;
}
const device = props.installation.device;
const OperatingPriorityOptions =
device === 3 || device === 4
? ['LoadPriority', 'BatteryPriority', 'GridPriority']
: [];
// Sinexcel S3 stores WorkingMode enum names — map them to Growatt-style display names
const sinexcelS3ToDisplayName: Record<string, string> = {
'SpontaneousSelfUse': 'LoadPriority',
'TimeChargeDischarge': 'BatteryPriority',
'PvPriorityCharging': 'GridPriority',
};
const [errors, setErrors] = useState({
minimumSoC: false,
gridSetPoint: false
});
const SetErrorForField = (field_name, state) => {
setErrors((prevErrors) => ({
...prevErrors,
[field_name]: state
}));
};
const theme = useTheme();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
const [dateSelectionError, setDateSelectionError] = useState('');
const [isErrorDateModalOpen, setErrorDateModalOpen] = useState(false);
const context = useContext(UserContext);
const { currentUser, setUser } = context;
const { product, setProduct } = useContext(ProductIdContext);
// Resolve S3 OperatingPriority to display index (Sinexcel uses different enum names)
const resolveOperatingPriorityIndex = (s3Value: string) => {
const displayName = device === 4 ? (sinexcelS3ToDisplayName[s3Value] ?? s3Value) : s3Value;
return OperatingPriorityOptions.indexOf(displayName);
};
// Storage key for pending config (optimistic update)
const pendingConfigKey = `pendingConfig_${props.id}`;
// Helper to build form values from S3 data
const getS3Values = (): Partial<ConfigurationValues> => ({
minimumSoC: props.values.Config.MinSoc,
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
operatingPriority: resolveOperatingPriorityIndex(
props.values.Config.OperatingPriority
),
batteriesCount: props.values.Config.BatteriesCount,
clusterNumber: props.values.Config.ClusterNumber ?? 1,
PvNumber: props.values.Config.PvNumber ?? 0,
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
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",
});
// Restore pending config from localStorage, converting date strings back to Date objects.
// Returns { values, s3ConfigSnapshot } or null if no pending config.
const restorePendingConfig = () => {
try {
const pendingStr = localStorage.getItem(pendingConfigKey);
if (!pendingStr) return null;
const pending = JSON.parse(pendingStr);
const v = pending.values;
const values: Partial<ConfigurationValues> = {
...v,
// JSON.stringify converts Date→string; restore them back to Date objects
startTimeChargeandDischargeDayandTime:
v.startTimeChargeandDischargeDayandTime
? dayjs(v.startTimeChargeandDischargeDayandTime).toDate()
: null,
stopTimeChargeandDischargeDayandTime:
v.stopTimeChargeandDischargeDayandTime
? dayjs(v.stopTimeChargeandDischargeDayandTime).toDate()
: null,
};
return { values, s3ConfigSnapshot: pending.s3ConfigSnapshot || null };
} catch (e) {
console.error('[Config:restore] Failed to parse localStorage', e);
localStorage.removeItem(pendingConfigKey);
return null;
}
};
// Fingerprint S3 Config for change detection (not value comparison)
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
// Initialize form from localStorage (if pending submit exists) or from S3
// This runs in the useState initializer so the component never renders stale values
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
const pending = restorePendingConfig();
const s3 = getS3Values();
if (pending) {
// Check if S3 has new data since submit (fingerprint changed from snapshot)
const currentFingerprint = getS3ConfigFingerprint();
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
if (s3Changed) {
// Device uploaded new data since our submit — trust S3 (device is authority)
localStorage.removeItem(pendingConfigKey);
return s3;
}
// S3 still has same data as when we submitted — show pending values
return pending.values;
}
return s3;
});
// When S3 data updates (polled every 60s), reconcile with any pending localStorage.
// Strategy: device is the authority. Once S3 Config changes from the snapshot taken at
// submit time, the device has uploaded new data — trust S3 regardless of values.
useEffect(() => {
const s3Values = getS3Values();
const pending = restorePendingConfig();
if (pending) {
const currentFingerprint = getS3ConfigFingerprint();
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
if (s3Changed) {
// S3 Config changed from snapshot → device uploaded new data → trust S3
localStorage.removeItem(pendingConfigKey);
setFormValues(s3Values);
} else {
// S3 still has same data as at submit time — keep showing pending values
setFormValues(pending.values);
}
return;
}
// No pending config — trust S3 (source of truth)
setFormValues(s3Values);
}, [props.values]);
const handleOperatingPriorityChange = (event) => {
setFormValues({
...formValues,
['operatingPriority']: OperatingPriorityOptions.indexOf(
event.target.value
)
});
};
// Add time validation function — only relevant for Sinexcel BatteryPriority
const validateTimeOnly = () => {
if (device === 4 &&
OperatingPriorityOptions[formValues.operatingPriority] === 'BatteryPriority' &&
formValues.startTimeChargeandDischargeDayandTime &&
formValues.stopTimeChargeandDischargeDayandTime) {
const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours();
const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes();
const stopHours = formValues.stopTimeChargeandDischargeDayandTime.getHours();
const stopMinutes = formValues.stopTimeChargeandDischargeDayandTime.getMinutes();
const startTimeInMinutes = startHours * 60 + startMinutes;
const stopTimeInMinutes = stopHours * 60 + stopMinutes;
if (startTimeInMinutes >= stopTimeInMinutes) {
setDateSelectionError(intl.formatMessage({ id: 'stopTimeMustBeLater' }));
setErrorDateModalOpen(true);
return false;
}
}
return true;
};
const handleSubmit = async (e) => {
if (!validateTimeOnly()) {
return;
}
const configurationToSend: Partial<ConfigurationValues> = {
minimumSoC: formValues.minimumSoC,
maximumDischargingCurrent: formValues.maximumDischargingCurrent,
maximumChargingCurrent: formValues.maximumChargingCurrent,
operatingPriority: formValues.operatingPriority,
batteriesCount:formValues.batteriesCount,
clusterNumber:formValues.clusterNumber,
PvNumber:formValues.PvNumber,
timeChargeandDischargePower: formValues.timeChargeandDischargePower,
startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime
? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
: null,
stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
: null,
controlPermission:formValues.controlPermission
};
setLoading(true);
const res = await axiosConfig
.post(
`/EditInstallationConfig?installationId=${props.id}&product=${product}`,
configurationToSend
)
.catch((err) => {
if (err.response) {
setError(true);
setLoading(false);
}
});
if (res) {
setUpdated(true);
setLoading(false);
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
// When S3 Config changes from this snapshot, the device has uploaded new data.
const cachePayload = {
values: formValues,
submittedAt: Date.now(),
s3ConfigSnapshot: getS3ConfigFingerprint(),
};
localStorage.setItem(pendingConfigKey, JSON.stringify(cachePayload));
}
};
const handleChange = (e) => {
const { name, value } = e.target;
if (name === 'minimumSoC') {
const numValue = parseFloat(value);
// invalid characters or not a number
if (/[^0-9.]/.test(value) || isNaN(numValue)) {
SetErrorForField(name, 'Invalid number format');
} else {
const minsocRanges = {
3: { min: 10, max: 30 },
4: { min: 5, max: 100 },
};
const { min, max } = minsocRanges[device] || { min: 10, max: 30 };
if (numValue < min || numValue > max) {
SetErrorForField(name, `Value should be between ${min}-${max}%`);
} else {
// ✅ valid → clear error
SetErrorForField(name, '');
}
}
}
setFormValues(prev => ({
...prev,
[name]: value,
}));
};
const handleTimeChargeDischargeChange = (name: string, value: any) => {
setFormValues((prev) => ({
...prev,
[name]: value
}));
};
const handleOkOnErrorDateModal = () => {
setErrorDateModalOpen(false);
};
return (
<Container maxWidth="xl">
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={3}
>
{isErrorDateModalOpen && (
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
<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' }}
>
{dateSelectionError}
</Typography>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={handleOkOnErrorDateModal}
>
Ok
</Button>
</Box>
</Modal>
)}
<Grid item xs={12} md={12}>
<CardContent>
<Box
component="form"
sx={{
'& .MuiTextField-root': { m: 1, width: 390 }
}}
noValidate
autoComplete="off"
>
<>
<div style={{ marginBottom: '5px' }}>
<FormControlLabel
labelPlacement="start"
control={
<Switch
name="controlPermission"
checked={Boolean(formValues.controlPermission)}
onChange={(e) =>
setFormValues((prev) => ({
...prev,
controlPermission: e.target.checked,
}))
}
sx={{ transform: "scale(1.4)", marginLeft: "15px" }}
/>
}
label={
<FormattedMessage
id="controlPermission"
defaultMessage="Control Permission"
/>
}
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="batteriesCount "
defaultMessage="Batteries Count"
/>
}
name="batteriesCount"
value={formValues.batteriesCount}
onChange={handleChange}
fullWidth
/>
</div>
{device === 4 && (
<>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="clusterNumber"
defaultMessage="Cluster Number"
/>
}
name="clusterNumber"
value={formValues.clusterNumber}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="PvNumber"
defaultMessage="PV Number"
/>
}
name="PvNumber"
value={formValues.PvNumber}
onChange={handleChange}
fullWidth
/>
</div>
</>
)}
<div style={{ marginBottom: '5px' }}>
{/*<TextField*/}
{/* label={*/}
{/* <FormattedMessage*/}
{/* id="minimum_soc "*/}
{/* defaultMessage="Minimum SoC (%)"*/}
{/* />*/}
{/* }*/}
{/* name="minimumSoC"*/}
{/* value={formValues.minimumSoC}*/}
{/* onChange={handleChange}*/}
{/* helperText={*/}
{/* errors.minimumSoC ? (*/}
{/* <span style={{ color: 'red' }}>*/}
{/* Value should be between {device === 4 ? '5100' : '1030'}%*/}
{/* </span>*/}
{/* ) : (*/}
{/* ''*/}
{/* )*/}
{/* }*/}
{/* fullWidth*/}
{/*/>*/}
<TextField
label={intl.formatMessage({ id: 'minimumSocPercent' })}
name="minimumSoC"
value={formValues.minimumSoC}
onChange={handleChange}
error={Boolean(errors.minimumSoC)}
helperText={errors.minimumSoC}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="maximumChargingCurrent "
defaultMessage="Maximum Charging Current"
/>
}
name="maximumChargingCurrent"
value={formValues.maximumChargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="maximumDischargingCurrent "
defaultMessage="Maximum Discharging Current"
/>
}
name="maximumDischargingCurrent"
value={formValues.maximumDischargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="operating_priority"
defaultMessage="Operating Priority"
/>
</InputLabel>
<Select
value={
OperatingPriorityOptions[formValues.operatingPriority]
}
onChange={handleOperatingPriorityChange}
>
{OperatingPriorityOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</>
{/* --- Sinexcel + BatteryPriority (maps to TimeChargeDischarge on device) --- */}
{device === 4 &&
OperatingPriorityOptions[formValues.operatingPriority] ===
'BatteryPriority' && (
<>
{/* Power input*/}
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'powerW' })}
name="timeChargeandDischargePower"
value={formValues.timeChargeandDischargePower}
onChange={(e) =>
handleTimeChargeDischargeChange(e.target.name, e.target.value)
}
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
fullWidth
/>
</div>
{/* Start DateTime */}
<div style={{ marginBottom: '5px' }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
ampm={false}
label={intl.formatMessage({ id: 'startDateTime' })}
value={
formValues.startTimeChargeandDischargeDayandTime
? dayjs(formValues.startTimeChargeandDischargeDayandTime)
: null
}
onChange={(newValue) =>
setFormValues((prev) => ({
...prev,
startTimeChargeandDischargeDayandTime: newValue
? newValue.toDate()
: null,
}))
}
renderInput={(params) => (
<TextField
{...params}
sx={{
marginTop: 2,
width: '100%',
}}
/>
)}
/>
</LocalizationProvider>
</div>
{/* Stop DateTime */}
<div style={{ marginBottom: '5px' }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
ampm={false}
label={intl.formatMessage({ id: 'stopDateTime' })}
value={
formValues.stopTimeChargeandDischargeDayandTime
? dayjs(formValues.stopTimeChargeandDischargeDayandTime)
: null
}
onChange={(newValue) =>
setFormValues((prev) => ({
...prev,
stopTimeChargeandDischargeDayandTime: newValue
? newValue.toDate()
: null,
}))
}
renderInput={(params) => (
<TextField
{...params}
sx={{
marginTop: 2,
width: '100%',
}}
/>
)}
/>
</LocalizationProvider>
</div>
</>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
variant="contained"
onClick={handleSubmit}
sx={{
marginLeft: '10px'
}}
>
<FormattedMessage
id="applychanges"
defaultMessage="Apply Changes"
/>
</Button>
{loading && (
<CircularProgress
sx={{
color: theme.palette.primary.main,
marginLeft: '20px'
}}
/>
)}
{updated && (
<Alert
severity="success"
sx={{
ml: 1,
display: 'flex',
alignItems: 'center'
}}
>
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
<IconButton
color="inherit"
size="small"
onClick={() => setUpdated(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
{error && (
<Alert
severity="error"
sx={{
ml: 1,
display: 'flex',
alignItems: 'center'
}}
>
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</CardContent>
</Grid>
</Grid>
</Container>
);
}
export default SodistoreHomeConfiguration;