import React, { Component } from 'react'; import { Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, FormControl, InputLabel, Select, MenuItem, Box, IconButton, Switch, FormControlLabel, Chip, Divider, Tooltip } from '@mui/material'; import RuleIcon from '@mui/icons-material/Rule'; import AddIcon from '@mui/icons-material/Add'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PauseIcon from '@mui/icons-material/Pause'; // Condition operators by type const CONDITION_OPERATORS = { time: [ { value: 'between', label: 'Between' }, { value: '=', label: '=' }, { value: '<', label: '<' }, { value: '>', label: '>' } ], date: [ { value: 'before', label: 'Before' }, { value: 'after', label: 'After' }, { value: 'between', label: 'Between' } ], sensor: [ { value: '=', label: '=' }, { value: '!=', label: '!=' }, { value: '<', label: '<' }, { value: '>', label: '>' }, { value: '<=', label: '<=' }, { value: '>=', label: '>=' } ], output: [ { value: '=', label: '=' }, { value: '!=', label: '!=' }, { value: '<', label: '<' }, { value: '>', label: '>' }, { value: '<=', label: '<=' }, { value: '>=', label: '>=' } ] }; class RuleEditor extends Component { constructor(props) { super(props); this.state = { rules: [], outputChannels: [], devices: [], outputValues: {}, // Dialog state open: false, editingId: null, ruleName: '', ruleEnabled: true, conditions: { operator: 'AND', conditions: [] }, action: { channel: '', value: 0 } }; } componentDidMount() { this.refreshRules(); this.loadOutputChannels(); this.loadDevices(); this.loadOutputValues(); // Refresh output values every 10s this.refreshInterval = setInterval(() => this.loadOutputValues(), 10000); } componentWillUnmount() { if (this.refreshInterval) clearInterval(this.refreshInterval); } isAdmin() { const { user } = this.props; return user && user.role === 'admin'; } refreshRules = () => { fetch('/api/rules') .then(res => res.json()) .then(rules => this.setState({ rules })) .catch(console.error); }; loadOutputChannels = () => { fetch('/api/outputs') .then(res => res.json()) .then(outputChannels => this.setState({ outputChannels })) .catch(console.error); }; loadDevices = () => { fetch('/api/devices') .then(res => res.json()) .then(devices => this.setState({ devices })) .catch(console.error); }; loadOutputValues = () => { fetch('/api/outputs/values') .then(res => res.json()) .then(outputValues => this.setState({ outputValues })) .catch(console.error); }; // Dialog handlers handleOpenCreate = () => { this.setState({ editingId: null, ruleName: '', ruleEnabled: true, conditions: { operator: 'AND', conditions: [] }, action: { channel: this.state.outputChannels[0]?.channel || '', value: 0 }, open: true }); }; handleOpenEdit = (rule, e) => { e.stopPropagation(); this.setState({ editingId: rule.id, ruleName: rule.name, ruleEnabled: !!rule.enabled, conditions: rule.conditions || { operator: 'AND', conditions: [] }, action: rule.action || { channel: '', value: 0 }, open: true }); }; handleDelete = async (id, e) => { e.stopPropagation(); if (!window.confirm("Delete this rule?")) return; const { user } = this.props; await fetch(`/api/rules/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${user.token}` } }); this.refreshRules(); }; moveRule = async (idx, dir) => { const newRules = [...this.state.rules]; const target = idx + dir; if (target < 0 || target >= newRules.length) return; [newRules[idx], newRules[target]] = [newRules[target], newRules[idx]]; this.setState({ rules: newRules }); const order = newRules.map((r, i) => ({ id: r.id, position: i })); const { user } = this.props; try { await fetch('/api/rules/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${user.token}` }, body: JSON.stringify({ order }) }); } catch (err) { console.error("Failed to save order", err); } }; handleSave = async () => { const { ruleName, ruleEnabled, conditions, action, editingId } = this.state; const { user } = this.props; if (!ruleName || !action.channel) { alert('Please fill in all required fields'); return; } const url = editingId ? `/api/rules/${editingId}` : '/api/rules'; const method = editingId ? 'PUT' : 'POST'; try { const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${user.token}` }, body: JSON.stringify({ name: ruleName, enabled: ruleEnabled, conditions, action }) }); if (res.ok) { this.setState({ open: false }); this.refreshRules(); } else { const err = await res.json(); alert('Failed to save rule: ' + err.error); } } catch (err) { console.error(err); } }; toggleRuleEnabled = async (rule) => { const { user } = this.props; try { await fetch(`/api/rules/${rule.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${user.token}` }, body: JSON.stringify({ ...rule, enabled: !rule.enabled }) }); this.refreshRules(); } catch (err) { console.error(err); } }; // Condition editing addCondition = (parentPath = []) => { this.setState(prev => { const newConditions = JSON.parse(JSON.stringify(prev.conditions)); let target = newConditions; for (const idx of parentPath) { target = target.conditions[idx]; } target.conditions.push({ type: 'sensor', operator: '>', channel: '', value: 0 }); return { conditions: newConditions }; }); }; addConditionGroup = (parentPath = [], groupType = 'AND') => { this.setState(prev => { const newConditions = JSON.parse(JSON.stringify(prev.conditions)); let target = newConditions; for (const idx of parentPath) { target = target.conditions[idx]; } target.conditions.push({ operator: groupType, conditions: [] }); return { conditions: newConditions }; }); }; updateCondition = (path, updates) => { this.setState(prev => { const newConditions = JSON.parse(JSON.stringify(prev.conditions)); let target = newConditions; for (let i = 0; i < path.length - 1; i++) { target = target.conditions[path[i]]; } const idx = path[path.length - 1]; target.conditions[idx] = { ...target.conditions[idx], ...updates }; return { conditions: newConditions }; }); }; removeCondition = (path) => { this.setState(prev => { const newConditions = JSON.parse(JSON.stringify(prev.conditions)); let target = newConditions; for (let i = 0; i < path.length - 1; i++) { target = target.conditions[path[i]]; } const idx = path[path.length - 1]; target.conditions.splice(idx, 1); return { conditions: newConditions }; }); }; toggleGroupOperator = (path) => { this.setState(prev => { const newConditions = JSON.parse(JSON.stringify(prev.conditions)); let target = newConditions; for (const idx of path) { target = target.conditions[idx]; } if (path.length === 0) { // Root level newConditions.operator = newConditions.operator === 'AND' ? 'OR' : 'AND'; } else { target.operator = target.operator === 'AND' ? 'OR' : 'AND'; } return { conditions: newConditions }; }); }; // Render a condition group recursively renderConditionGroup = (group, path = []) => { const { devices, outputChannels } = this.state; const isRoot = path.length === 0; // Build sensor channels list const sensorChannels = devices.map(d => `${d.device}:${d.channel}`); return ( this.toggleGroupOperator(path) : undefined} sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }} /> {this.isAdmin() && ( <> )} {group.conditions?.map((cond, idx) => { const condPath = [...path, idx]; // Nested group if (cond.operator === 'AND' || cond.operator === 'OR') { return ( {this.renderConditionGroup(cond, condPath)} {this.isAdmin() && ( this.removeCondition(condPath)}> )} ); } // Single condition return ( {(cond.type === 'sensor' || cond.type === 'output') && ( )} this.setState({ action: { ...action, channel: e.target.value } })} sx={{ minWidth: 200 }} > {outputChannels.map(ch => ( {ch.description} ({ch.channel}) ))} to this.setState({ action: { ...action, value: parseFloat(e.target.value) || 0 } })} inputProps={{ min: outputChannels.find(c => c.channel === action.channel)?.min || 0, max: outputChannels.find(c => c.channel === action.channel)?.max || 10 }} sx={{ width: 100 }} /> ); } } export default RuleEditor;