Files
wolfDash/src/client/RuleEditor.js
sebseb7 5febdf29c8 i18n
2025-12-21 03:46:50 +01:00

732 lines
34 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
FormControl,
InputLabel,
Select,
MenuItem,
ToggleButton,
ToggleButtonGroup,
Typography,
Divider,
Slider,
Switch,
FormControlLabel,
CircularProgress,
IconButton,
Paper,
Chip
} from '@mui/material';
import { useI18n } from './I18nContext';
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const OPERATORS = [
{ value: '>', label: '>' },
{ value: '<', label: '<' },
{ value: '>=', label: '≥' },
{ value: '<=', label: '≤' },
{ value: '==', label: '=' }
];
// Single sensor condition component
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
const selectedSensor = sensors.find(s => s.id === condition.sensor);
const isStateSensor = selectedSensor?.type === 'output-state';
// When sensor changes, reset operator to appropriate default
const handleSensorChange = (newSensorId) => {
const newSensor = sensors.find(s => s.id === newSensorId);
const newIsState = newSensor?.type === 'output-state';
onChange({
...condition,
sensor: newSensorId,
operator: newIsState ? '==' : '>',
value: newIsState ? 1 : (condition.value ?? 25)
});
};
return (
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Sensor</InputLabel>
<Select
value={condition.sensor || ''}
label="Sensor"
onChange={(e) => handleSensorChange(e.target.value)}
disabled={disabled}
>
{sensors.map(s => (
<MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
))}
</Select>
</FormControl>
{isStateSensor ? (
// State sensor: is on / is off
<FormControl size="small" sx={{ minWidth: 100 }}>
<Select
value={condition.value === 1 ? 'on' : 'off'}
onChange={(e) => onChange({ ...condition, operator: '==', value: e.target.value === 'on' ? 1 : 0 })}
disabled={disabled}
>
<MenuItem value="on">is ON</MenuItem>
<MenuItem value="off">is OFF</MenuItem>
</Select>
</FormControl>
) : (
// Value sensor: numeric comparison
<>
<FormControl size="small" sx={{ minWidth: 70 }}>
<Select
value={condition.operator || '>'}
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
disabled={disabled}
>
{OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
value={condition.value ?? ''}
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
sx={{ width: 80 }}
disabled={disabled}
/>
</>
)}
{onRemove && (
<IconButton size="small" onClick={onRemove} disabled={disabled}>
</IconButton>
)}
</Paper>
);
}
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
const { t } = useI18n();
const [name, setName] = useState('');
const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
// Scheduled time state (trigger at specific time)
const [useScheduledTime, setUseScheduledTime] = useState(false);
const [scheduledTime, setScheduledTime] = useState('08:00');
const [scheduledDays, setScheduledDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
// Time range state (active during window)
const [useTimeRange, setUseTimeRange] = useState(false);
const [timeStart, setTimeStart] = useState('08:00');
const [timeEnd, setTimeEnd] = useState('18:00');
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
// Sensor conditions state
const [useSensors, setUseSensors] = useState(false);
const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
const [sensorLogic, setSensorLogic] = useState('and'); // 'and' or 'or'
// Action state
const [actionType, setActionType] = useState('toggle');
const [target, setTarget] = useState('');
const [toggleState, setToggleState] = useState(true);
const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs
const [duration, setDuration] = useState(15);
// Check if target is a binary (on/off) output or level (1-10) output
const selectedOutput = outputs.find(o => o.id === target);
const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
// Reset form when rule changes or dialog opens
useEffect(() => {
if (rule) {
setName(rule.name);
// colorTags can be array or single value for backwards compat
const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
setSelectedTags(Array.isArray(tags) ? tags : []);
// Parse trigger
const trigger = rule.trigger || {};
// Scheduled time
setUseScheduledTime(!!trigger.scheduledTime);
if (trigger.scheduledTime) {
setScheduledTime(trigger.scheduledTime.time || '08:00');
setScheduledDays(trigger.scheduledTime.days || []);
}
// Time range
setUseTimeRange(!!trigger.timeRange);
if (trigger.timeRange) {
setTimeStart(trigger.timeRange.start || '08:00');
setTimeEnd(trigger.timeRange.end || '18:00');
setTimeRangeDays(trigger.timeRange.days || []);
}
setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
if (trigger.sensors && trigger.sensors.length > 0) {
setSensorConditions(trigger.sensors);
setSensorLogic(trigger.sensorLogic || 'and');
}
// Parse action
setActionType(rule.action?.type || 'toggle');
setTarget(rule.action?.target || '');
if (rule.action?.type === 'toggle') {
setToggleState(rule.action?.state ?? true);
setOutputLevel(rule.action?.level ?? 5);
} else {
setDuration(rule.action?.duration || 15);
}
} else {
// Reset to defaults
setName('');
setSelectedTags([]);
setUseScheduledTime(true);
setScheduledTime('08:00');
setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setUseTimeRange(false);
setTimeStart('08:00');
setTimeEnd('18:00');
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setUseSensors(false);
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
setSensorLogic('and');
setActionType('toggle');
setTarget(outputs[0]?.id || '');
setToggleState(true);
setOutputLevel(5);
setDuration(15);
}
}, [rule, open, sensors, outputs]);
// Set default sensor/output when lists load
useEffect(() => {
if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
}
if (!target && outputs.length > 0) setTarget(outputs[0].id);
}, [sensors, outputs, sensorConditions, target]);
const handleScheduledDaysChange = (event, newDays) => {
if (newDays.length > 0) setScheduledDays(newDays);
};
const handleTimeRangeDaysChange = (event, newDays) => {
if (newDays.length > 0) setTimeRangeDays(newDays);
};
const addSensorCondition = () => {
setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
};
const updateSensorCondition = (index, newCondition) => {
const updated = [...sensorConditions];
updated[index] = newCondition;
setSensorConditions(updated);
};
const removeSensorCondition = (index) => {
if (sensorConditions.length > 1) {
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
}
};
const handleSave = () => {
const selectedOutput = outputs.find(o => o.id === target);
// Build trigger object
const trigger = {};
if (useScheduledTime) {
trigger.scheduledTime = { time: scheduledTime, days: scheduledDays };
}
if (useTimeRange) {
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
}
if (useSensors && sensorConditions.length > 0) {
trigger.sensors = sensorConditions.map(c => ({
...c,
sensorLabel: sensors.find(s => s.id === c.sensor)?.label
}));
trigger.sensorLogic = sensorLogic;
}
const isBinaryTarget = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
// Build action object based on output type
let action;
if (actionType === 'toggle') {
action = {
type: 'toggle',
target,
targetLabel: selectedOutput?.label,
...(isBinaryTarget ? { state: toggleState } : { level: outputLevel })
};
} else {
action = {
type: 'keepOn',
target,
targetLabel: selectedOutput?.label,
duration,
...(isBinaryTarget ? {} : { level: outputLevel })
};
}
const ruleData = { name, trigger, action, colorTags: selectedTags };
onSave(ruleData);
};
const isValid = name.trim().length > 0 &&
(useScheduledTime || useTimeRange || useSensors) &&
(!useScheduledTime || scheduledDays.length > 0) &&
(!useTimeRange || timeRangeDays.length > 0) &&
(!useSensors || sensorConditions.every(c => c.sensor)) &&
target;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
border: '1px solid #504945'
}
}}
>
<DialogTitle>
{rule ? t('ruleEditor.editTitle') : t('ruleEditor.createTitle')}
</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
{/* Rule Name */}
<TextField
label={t('ruleEditor.ruleName')}
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
placeholder={t('ruleEditor.ruleNamePlaceholder')}
disabled={saving}
/>
{/* Color Tag Picker (multi-select) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">Tags:</Typography>
<Box
onClick={() => setSelectedTags([])}
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: '#504945',
cursor: 'pointer',
border: selectedTags.length === 0 ? '3px solid #ebdbb2' : '2px solid #504945',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.7rem',
'&:hover': { opacity: 0.8 }
}}
>
</Box>
{availableColorTags.map(tag => (
<Box
key={tag.id}
onClick={() => {
if (selectedTags.includes(tag.id)) {
setSelectedTags(selectedTags.filter(t => t !== tag.id));
} else {
setSelectedTags([...selectedTags, tag.id]);
}
}}
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: tag.color,
cursor: 'pointer',
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': { opacity: 0.8 }
}}
>
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}></span>}
</Box>
))}
</Box>
{/* TRIGGERS SECTION */}
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{t('ruleEditor.triggersSection')}
</Typography>
<Divider sx={{ mb: 2 }} />
{/* Scheduled Time Trigger (fires at exact time) */}
<Paper sx={{ p: 2, mb: 2, bgcolor: useScheduledTime ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch
checked={useScheduledTime}
onChange={(e) => setUseScheduledTime(e.target.checked)}
disabled={saving}
/>
}
label={<Typography fontWeight={600}>{t('ruleEditor.scheduledTime')}</Typography>}
/>
{useScheduledTime && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label={t('ruleEditor.triggerAt')}
type="time"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
sx={{ width: 150 }}
disabled={saving}
/>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('ruleEditor.days')}
</Typography>
<ToggleButtonGroup
value={scheduledDays}
onChange={handleScheduledDaysChange}
size="small"
disabled={saving}
>
{DAYS_KEYS.map(key => (
<ToggleButton
key={key}
value={key}
sx={{
'&.Mui-selected': {
bgcolor: '#d3869b',
color: '#282828',
'&:hover': { bgcolor: '#e396a5' }
}
}}
>
{t(`days.${key}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box>
)}
</Paper>
{/* Time Range Trigger (active within window) */}
<Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch
checked={useTimeRange}
onChange={(e) => setUseTimeRange(e.target.checked)}
disabled={saving}
/>
}
label={<Typography fontWeight={600}> Time Range (active during window)</Typography>}
/>
{useTimeRange && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="From"
type="time"
value={timeStart}
onChange={(e) => setTimeStart(e.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
disabled={saving}
/>
<Typography>to</Typography>
<TextField
label="Until"
type="time"
value={timeEnd}
onChange={(e) => setTimeEnd(e.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
disabled={saving}
/>
</Box>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('ruleEditor.days')}
</Typography>
<ToggleButtonGroup
value={timeRangeDays}
onChange={handleTimeRangeDaysChange}
size="small"
disabled={saving}
>
{DAYS_KEYS.map(key => (
<ToggleButton
key={key}
value={key}
sx={{
'&.Mui-selected': {
bgcolor: '#8ec07c',
color: '#282828',
'&:hover': { bgcolor: '#98c98a' }
}
}}
>
{t(`days.${key}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box>
)}
</Paper>
{/* Sensor Conditions Trigger */}
<Paper sx={{ p: 2, bgcolor: useSensors ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch
checked={useSensors}
onChange={(e) => setUseSensors(e.target.checked)}
disabled={saving || sensors.length === 0}
/>
}
label={
<Typography fontWeight={600}>
📊 Sensor Conditions {sensors.length === 0 && '(no sensors available)'}
</Typography>
}
/>
{useSensors && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{sensorConditions.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">Combine conditions with:</Typography>
<ToggleButtonGroup
value={sensorLogic}
exclusive
onChange={(e, v) => v && setSensorLogic(v)}
size="small"
disabled={saving}
>
<ToggleButton value="and" sx={{ '&.Mui-selected': { bgcolor: '#8ec07c', color: '#282828' } }}>
AND
</ToggleButton>
<ToggleButton value="or" sx={{ '&.Mui-selected': { bgcolor: '#fabd2f', color: '#282828' } }}>
OR
</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{sensorConditions.map((cond, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{i > 0 && (
<Chip
label={sensorLogic.toUpperCase()}
size="small"
sx={{
bgcolor: sensorLogic === 'and' ? '#8ec07c' : '#fabd2f',
color: '#282828',
fontWeight: 600,
minWidth: 45
}}
/>
)}
<SensorCondition
condition={cond}
sensors={sensors}
onChange={(newCond) => updateSensorCondition(i, newCond)}
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
disabled={saving}
/>
</Box>
))}
<Button
size="small"
onClick={addSensorCondition}
disabled={saving}
sx={{ alignSelf: 'flex-start' }}
>
+ Add Condition
</Button>
</Box>
)}
</Paper>
</Box>
{/* ACTION SECTION */}
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
ACTION (What to do)
</Typography>
<Divider sx={{ mb: 2 }} />
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Action Type</InputLabel>
<Select
value={actionType}
label="Action Type"
onChange={(e) => setActionType(e.target.value)}
disabled={saving}
>
<MenuItem value="toggle">🔛 Toggle On/Off</MenuItem>
<MenuItem value="keepOn"> Keep On for X Minutes</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Target Output</InputLabel>
<Select
value={target}
label="Target Output"
onChange={(e) => setTarget(e.target.value)}
disabled={saving}
>
{outputs.map(o => (
<MenuItem key={o.id} value={o.id}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
{/* Toggle action controls */}
{actionType === 'toggle' && (
<Box>
{isBinaryOutput ? (
// Binary output: On/Off switch
<FormControlLabel
control={
<Switch
checked={toggleState}
onChange={(e) => setToggleState(e.target.checked)}
color="primary"
disabled={saving}
/>
}
label={toggleState ? 'Turn ON' : 'Turn OFF'}
/>
) : (
// Level output: 1-10 slider
<Box>
<Typography variant="body2" color="text.secondary">
Set Level: {outputLevel}
</Typography>
<Slider
value={outputLevel}
onChange={(e, val) => setOutputLevel(val)}
min={1}
max={10}
step={1}
marks={[
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
</Box>
)}
</Box>
)}
{/* Keep On action controls */}
{actionType === 'keepOn' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{!isBinaryOutput && (
// Level for port outputs
<Box>
<Typography variant="body2" color="text.secondary">
Set Level: {outputLevel}
</Typography>
<Slider
value={outputLevel}
onChange={(e, val) => setOutputLevel(val)}
min={1}
max={10}
step={1}
marks={[
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
</Box>
)}
<Box>
<Typography variant="body2" color="text.secondary">
Duration: {duration} minutes
</Typography>
<Slider
value={duration}
onChange={(e, val) => setDuration(val)}
min={1}
max={120}
marks={[
{ value: 1, label: '1m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' }
]}
valueLabelDisplay="auto"
disabled={saving}
/>
</Box>
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit" disabled={saving}>
{t('ruleEditor.cancel')}
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={!isValid || saving}
sx={{
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
'&:hover': {
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
}
}}
>
{saving ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="inherit" />
{t('ruleEditor.saving')}
</Box>
) : (
rule ? t('ruleEditor.saveChanges') : t('ruleEditor.createRule')
)}
</Button>
</DialogActions>
</Dialog >
);
}