design new configuration tab for sodistore grid and meerge to main branch

This commit is contained in:
Yinyin Liu 2026-05-19 17:05:58 +02:00
parent 8b8fe8cf2e
commit 77abe03f9c
8 changed files with 178 additions and 122 deletions

View File

@ -28,6 +28,13 @@ public class Configuration
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
public DateTime? StopTimeChargeandDischargeDayandTime { get; set; }
// SodistoreGrid: inverter battery-limit settings (Sinexcel) — surfaced on Configuration tab.
public double? ActivePowerPercent { get; set; }
public double? MinDischargeVoltage { get; set; }
public double? MaxDischargeCurrent { get; set; }
public double? MaxChargeCurrent { get; set; }
public double? MaxChargeVoltage { get; set; }
// Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later.
public string? DynamicPricingMode { get; set; }
public string? NetworkProvider { get; set; }

View File

@ -1,4 +1,4 @@
import { ConfigurationValues, JSONRecordData } from '../Log/graph.util';
import { JSONRecordData } from '../Log/graph.util';
import {
Alert,
Box,
@ -22,94 +22,147 @@ interface ConfigurationSodistoreGridProps {
id: number;
}
function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
const intl = useIntl();
if (props.values === null) {
return null;
// Wire-format keys (camelCase → backend Configuration PascalCase via case-insensitive bind).
interface GridFormValues {
minimumSoC?: string;
activePowerPercent?: string;
minDischargeVoltage?: string;
maxDischargeCurrent?: string;
maxChargeCurrent?: string;
maxChargeVoltage?: string;
}
// Per-field validation rules.
// type="text" + regex (NOT type="number" — avoids spinner & locale issues, per project rule).
const FIELDS: {
name: keyof GridFormValues;
labelId: string;
min?: number;
max?: number;
allowNegative?: boolean;
}[] = [
{ name: 'minimumSoC', labelId: 'minimumSocPercent', min: 0, max: 100 },
{ name: 'activePowerPercent', labelId: 'activePowerPercent', min: -100, max: 100, allowNegative: true },
{ name: 'minDischargeVoltage', labelId: 'minDischargeVoltageV', min: 0 },
{ name: 'maxDischargeCurrent', labelId: 'maxDischargeCurrentA', min: 0 },
{ name: 'maxChargeCurrent', labelId: 'maxChargeCurrentA', min: 0 },
{ name: 'maxChargeVoltage', labelId: 'maxChargeVoltageV', min: 0 }
];
function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
const intl = useIntl();
const theme = useTheme();
// All hooks must be called unconditionally (Rules of Hooks). The null-check
// moved BELOW the hook declarations.
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
const [errors, setErrors] = useState({
// Lazy initializer reads from props.values safely even when null.
const [formValues, setFormValues] = useState<GridFormValues>(() => {
const inv: any = props.values?.InverterRecord ?? {};
const cfg: any = props.values?.Config ?? {};
return {
minimumSoC: cfg.MinSoc != null ? String(cfg.MinSoc) : '',
activePowerPercent: cfg.ActivePowerPercent != null ? String(cfg.ActivePowerPercent) : '',
minDischargeVoltage: inv.MinDischargeVoltage != null ? String(inv.MinDischargeVoltage) : '',
maxDischargeCurrent: inv.MaxDischargeCurrent != null ? String(inv.MaxDischargeCurrent) : '',
maxChargeCurrent: inv.MaxChargeCurrent != null ? String(inv.MaxChargeCurrent) : '',
maxChargeVoltage: inv.MaxChargeVoltage != null ? String(inv.MaxChargeVoltage) : ''
};
});
const [errors, setErrors] = useState<Record<keyof GridFormValues, boolean>>({
minimumSoC: false,
gridSetPoint: false
activePowerPercent: false,
minDischargeVoltage: false,
maxDischargeCurrent: false,
maxChargeCurrent: false,
maxChargeVoltage: false
});
const SetErrorForField = (field_name: string, state: boolean) => {
setErrors((prevErrors) => ({
...prevErrors,
[field_name]: state
}));
if (props.values === null) {
return null;
}
const validate = (
field: (typeof FIELDS)[number],
raw: string
): boolean => {
if (raw.trim() === '') return false; // empty = "leave unchanged", not error
// Regex: allow leading minus only if field accepts negatives, optional digits + dot
const pattern = field.allowNegative ? /^-?\d*\.?\d*$/ : /^\d*\.?\d*$/;
if (!pattern.test(raw)) return true;
const n = parseFloat(raw);
if (isNaN(n)) return true;
if (field.min !== undefined && n < field.min) return true;
if (field.max !== undefined && n > field.max) return true;
return false;
};
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>({
minimumSoC: props.values.Config?.MinSoc,
gridSetPoint:
props.values.Config?.GridSetPoint != null
? (props.values.Config.GridSetPoint as number) / 1000
: undefined
});
const handleChange = (e) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const field = FIELDS.find((f) => f.name === name);
if (!field) return;
switch (name) {
case 'minimumSoC':
if (
/[^0-9.]/.test(value) ||
isNaN(parseFloat(value)) ||
parseFloat(value) > 100
) {
SetErrorForField(name, true);
} else {
SetErrorForField(name, false);
}
break;
case 'gridSetPoint':
if (/[^0-9.]/.test(value) || isNaN(parseFloat(value))) {
SetErrorForField(name, true);
} else {
SetErrorForField(name, false);
}
break;
default:
return true;
}
setFormValues({
...formValues,
[name]: value
});
setErrors((prev) => ({ ...prev, [name]: validate(field, value) }));
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const anyError = Object.values(errors).some(Boolean);
const allEmpty = Object.values(formValues).every(
(v) => !v || v.trim() === ''
);
const handleSubmit = async () => {
const configurationToSend: Partial<ConfigurationValues> = {
minimumSoC: formValues.minimumSoC,
gridSetPoint: formValues.gridSetPoint
};
setLoading(true);
const res = await axiosConfig
.post(
`/EditInstallationConfig?installationId=${props.id}&product=4`,
configurationToSend
)
.catch((err) => {
if (err.response) {
setError(true);
setLoading(false);
// Only send fields the user actually entered. Empty string = skip.
const payload: Record<string, number> = {};
FIELDS.forEach((f) => {
const raw = formValues[f.name];
if (raw && raw.trim() !== '') {
const n = parseFloat(raw);
if (!isNaN(n)) payload[f.name] = n;
}
});
if (res) {
if (Object.keys(payload).length === 0) return;
setLoading(true);
setError(false);
setUpdated(false);
try {
await axiosConfig.post(
`/EditInstallationConfig?installationId=${props.id}&product=4`,
payload
);
setUpdated(true);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
const helperFor = (name: keyof GridFormValues, hasError: boolean) => {
if (!hasError) return '';
const f = FIELDS.find((fld) => fld.name === name)!;
// Range-aware helper: 0100 → existing key; otherwise generic.
if (f.min === 0 && f.max === 100) {
return (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'valueBetween0And100' })}
</span>
);
}
return (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
</span>
);
};
return (
<Container maxWidth="xl">
<Grid
@ -123,59 +176,29 @@ function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
<CardContent>
<Box
component="form"
sx={{
'& .MuiTextField-root': { m: 1, width: 390 }
}}
sx={{ '& .MuiTextField-root': { m: 1, width: 390 } }}
noValidate
autoComplete="off"
onSubmit={(e) => {
// Prevent native form submit (Enter in TextField would navigate).
e.preventDefault();
handleSubmit();
}}
>
<div style={{ marginBottom: '5px' }}>
{FIELDS.map((f) => (
<div key={f.name} style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="minimum_soc "
defaultMessage="Minimum SoC (%)"
/>
}
name="minimumSoC"
value={formValues.minimumSoC ?? ''}
type="text"
label={<FormattedMessage id={f.labelId} />}
name={f.name}
value={formValues[f.name] ?? ''}
onChange={handleChange}
helperText={
errors.minimumSoC ? (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'valueBetween0And100' })}
</span>
) : (
''
)
}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="grid_set_point"
defaultMessage="Grid Set Point (kW)"
/>
}
name="gridSetPoint"
value={formValues.gridSetPoint ?? ''}
onChange={handleChange}
helperText={
errors.gridSetPoint ? (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
</span>
) : (
''
)
}
error={errors[f.name]}
helperText={helperFor(f.name, errors[f.name])}
fullWidth
/>
</div>
))}
<div
style={{
@ -187,12 +210,12 @@ function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
<Button
variant="contained"
onClick={handleSubmit}
disabled={errors.minimumSoC || errors.gridSetPoint}
disabled={anyError || loading || allEmpty}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage
id="applychanges"
defaultMessage="Apply Changes"
id="applyChanges"
defaultMessage="Apply changes"
/>
</Button>

View File

@ -54,6 +54,7 @@ function Installation(props: singleInstallationProps) {
const status = props.current_installation.status;
// For SodiStoreGrid (product 4), backend heartbeat path is broken (SinexcelCommunication
// hardcodes Product=2), so installation.status stays -1 even when S3 is fresh.
// TODO: remove this override once SinexcelCommunication/Program.cs derives Product from runtime config.
// Treat as Green when our S3 fetch succeeded (connected === true).
const [connected, setConnected] = useState(true);
const [loading, setLoading] = useState(true);

View File

@ -566,7 +566,12 @@ function Overview(props: OverviewProps) {
>
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button>
{props.device !== 3 && props.product !== 2 && props.product !== 4 && (
{/* Hide "Last week" for SodioHome (product 2) and SodiStoreGrid (product 4)
neither has aggregated weekly data. Uses context `product` because
`props.product` isn't passed from Installation.tsx; `props.device` is also
never passed, so the legacy `device !== 3` (Growatt) check was a no-op
Growatt installs all have product=2, so the product check covers them. */}
{product !== 2 && product !== 4 && (
<Button
variant="contained"
onClick={handleWeekData}

View File

@ -799,5 +799,10 @@
"checklistPhasePreparation": "Vorbereitung",
"checklistPhaseOnSite": "Vor Ort",
"checklistPhaseHandover": "Kundenübergabe",
"checklistPhaseComplete": "Abgeschlossen"
"checklistPhaseComplete": "Abgeschlossen",
"activePowerPercent": "Wirkleistung (%)",
"minDischargeVoltageV": "Min. Entladespannung (V)",
"maxDischargeCurrentA": "Max. Entladestrom (A)",
"maxChargeCurrentA": "Max. Ladestrom (A)",
"maxChargeVoltageV": "Max. Ladespannung (V)"
}

View File

@ -547,5 +547,10 @@
"checklistPhasePreparation": "Preparation",
"checklistPhaseOnSite": "On-site",
"checklistPhaseHandover": "Customer handover",
"checklistPhaseComplete": "Complete"
"checklistPhaseComplete": "Complete",
"activePowerPercent": "Active Power (%)",
"minDischargeVoltageV": "Min Discharge Voltage (V)",
"maxDischargeCurrentA": "Max Discharge Current (A)",
"maxChargeCurrentA": "Max Charge Current (A)",
"maxChargeVoltageV": "Max Charge Voltage (V)"
}

View File

@ -799,5 +799,10 @@
"checklistPhasePreparation": "Préparation",
"checklistPhaseOnSite": "Sur site",
"checklistPhaseHandover": "Transfert client",
"checklistPhaseComplete": "Terminé"
"checklistPhaseComplete": "Terminé",
"activePowerPercent": "Puissance active (%)",
"minDischargeVoltageV": "Tension de décharge min. (V)",
"maxDischargeCurrentA": "Courant de décharge max. (A)",
"maxChargeCurrentA": "Courant de charge max. (A)",
"maxChargeVoltageV": "Tension de charge max. (V)"
}

View File

@ -799,5 +799,10 @@
"checklistPhasePreparation": "Preparazione",
"checklistPhaseOnSite": "In sito",
"checklistPhaseHandover": "Consegna cliente",
"checklistPhaseComplete": "Completato"
"checklistPhaseComplete": "Completato",
"activePowerPercent": "Potenza attiva (%)",
"minDischargeVoltageV": "Tensione di scarica min. (V)",
"maxDischargeCurrentA": "Corrente di scarica max. (A)",
"maxChargeCurrentA": "Corrente di carica max. (A)",
"maxChargeVoltageV": "Tensione di carica max. (V)"
}