design new configuration tab for sodistore grid and meerge to main branch
This commit is contained in:
parent
8b8fe8cf2e
commit
77abe03f9c
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (props.values === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
// 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 (Object.keys(payload).length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await axiosConfig
|
||||
.post(
|
||||
`/EditInstallationConfig?installationId=${props.id}&product=4`,
|
||||
configurationToSend
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
setError(false);
|
||||
setUpdated(false);
|
||||
|
||||
if (res) {
|
||||
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: 0–100 → 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' }}>
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="minimum_soc "
|
||||
defaultMessage="Minimum SoC (%)"
|
||||
/>
|
||||
}
|
||||
name="minimumSoC"
|
||||
value={formValues.minimumSoC ?? ''}
|
||||
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>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{FIELDS.map((f) => (
|
||||
<div key={f.name} style={{ marginBottom: '5px' }}>
|
||||
<TextField
|
||||
type="text"
|
||||
label={<FormattedMessage id={f.labelId} />}
|
||||
name={f.name}
|
||||
value={formValues[f.name] ?? ''}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue